feat: enhance asset import functionality and unit tracking
- Added WHISPER_URL environment variable to docker-compose for audio transcription service. - Updated Nginx configuration to increase timeout settings for API requests. - Enhanced asset ingestion process to extract unit numbers from ZIP entry paths, supporting various naming conventions. - Implemented logic to split intensive courses into two regular courses during asset import. - Added new fields to the Asset and QuestionBank models to track unit numbers and source asset links. - Introduced backward-compatible fallbacks for fetching study plans and courses from legacy MySQL database. - Improved error handling and progress tracking during ZIP file uploads in the frontend. - Created a new SQL migration to add unit_number and source_asset_id columns to the assets and question_bank tables, along with necessary indexes for performance.
This commit is contained in:
@@ -60,10 +60,14 @@ EXTERNAL_ID_TIPO_NOTA=1
|
|||||||
# AWS S3 Configuration (audio storage)
|
# AWS S3 Configuration (audio storage)
|
||||||
# Bucket: openccb-802726101181-us-east-2-an
|
# Bucket: openccb-802726101181-us-east-2-an
|
||||||
# ----------------------------------------
|
# ----------------------------------------
|
||||||
|
ASSETS_STORAGE=s3
|
||||||
AWS_ACCESS_KEY_ID=
|
AWS_ACCESS_KEY_ID=
|
||||||
AWS_SECRET_ACCESS_KEY=
|
AWS_SECRET_ACCESS_KEY=
|
||||||
AWS_REGION=us-east-2
|
AWS_REGION=us-east-2
|
||||||
S3_BUCKET=openccb-802726101181-us-east-2-an
|
S3_BUCKET=openccb-802726101181-us-east-2-an
|
||||||
|
S3_ENDPOINT=
|
||||||
|
S3_PUBLIC_BASE_URL=
|
||||||
|
S3_FORCE_PATH_STYLE=false
|
||||||
|
|
||||||
# ----------------------------------------
|
# ----------------------------------------
|
||||||
# Mercado Pago Configuration
|
# Mercado Pago Configuration
|
||||||
|
|||||||
@@ -79,8 +79,14 @@ trap cleanup EXIT
|
|||||||
# Copiar archivos esenciales
|
# Copiar archivos esenciales
|
||||||
echo " 📋 Copiando archivos esenciales..."
|
echo " 📋 Copiando archivos esenciales..."
|
||||||
cp -r docker-compose.yml "$PROD_DIR/" 2>/dev/null || echo " ⚠️ docker-compose.yml no existe"
|
cp -r docker-compose.yml "$PROD_DIR/" 2>/dev/null || echo " ⚠️ docker-compose.yml no existe"
|
||||||
# NO copiar .env local - tiene configuraciones incorrectas para producción
|
# Usar .env local como fuente de verdad para producción (si existe)
|
||||||
echo " ℹ️ .env local NO se copia - se generará uno correcto en el servidor"
|
if [ -f ".env" ]; then
|
||||||
|
cp .env "$PROD_DIR/.env"
|
||||||
|
echo " ✅ .env local copiado (fuente de producción)"
|
||||||
|
else
|
||||||
|
echo " ⚠️ .env local no existe; se usará .env.example como fallback"
|
||||||
|
fi
|
||||||
|
# .env.example se mantiene como plantilla/documentación
|
||||||
cp -r .env.example "$PROD_DIR/" 2>/dev/null || true
|
cp -r .env.example "$PROD_DIR/" 2>/dev/null || true
|
||||||
|
|
||||||
# NO copiar ubuntu.pem - solo se usa localmente para SSH
|
# NO copiar ubuntu.pem - solo se usa localmente para SSH
|
||||||
@@ -347,17 +353,19 @@ echo " PROTOCOL: \$PROTOCOL"
|
|||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# ========================================
|
# ========================================
|
||||||
# GENERAR .ENV CORRECTO PARA PRODUCCION
|
# RESOLVER .ENV PARA PRODUCCION
|
||||||
# ========================================
|
# ========================================
|
||||||
echo "Generando configuracion .env para produccion..."
|
echo "Resolviendo configuracion .env para produccion..."
|
||||||
|
|
||||||
if [ ! -f ".env" ]; then
|
if [ ! -f ".env" ]; then
|
||||||
echo " Creando .env desde .env.example..."
|
echo " .env no existe en remoto; creando desde .env.example..."
|
||||||
if [ -f ".env.example" ]; then
|
if [ -f ".env.example" ]; then
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
else
|
else
|
||||||
touch .env
|
touch .env
|
||||||
fi
|
fi
|
||||||
|
else
|
||||||
|
echo " Usando .env existente (valores de produccion)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Generar DB_PASSWORD seguro
|
# Generar DB_PASSWORD seguro
|
||||||
@@ -435,6 +443,10 @@ echo "NEXT_PUBLIC_CMS_API_URL=\$CMS_URL" >> .env
|
|||||||
echo "NEXT_PUBLIC_LMS_API_URL=\$LMS_URL" >> .env
|
echo "NEXT_PUBLIC_LMS_API_URL=\$LMS_URL" >> .env
|
||||||
|
|
||||||
# Configurar S3 para almacenamiento de audio
|
# Configurar S3 para almacenamiento de audio
|
||||||
|
if ! grep -q "^ASSETS_STORAGE=" .env || grep -q "^ASSETS_STORAGE=$" .env; then
|
||||||
|
sed -i "/^ASSETS_STORAGE=/d" .env 2>/dev/null || true
|
||||||
|
echo "ASSETS_STORAGE=s3" >> .env
|
||||||
|
fi
|
||||||
if ! grep -q "^S3_BUCKET=" .env || grep -q "^S3_BUCKET=$" .env; then
|
if ! grep -q "^S3_BUCKET=" .env || grep -q "^S3_BUCKET=$" .env; then
|
||||||
sed -i "/^S3_BUCKET=/d" .env 2>/dev/null || true
|
sed -i "/^S3_BUCKET=/d" .env 2>/dev/null || true
|
||||||
echo "S3_BUCKET=openccb-802726101181-us-east-2-an" >> .env
|
echo "S3_BUCKET=openccb-802726101181-us-east-2-an" >> .env
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ services:
|
|||||||
- DATABASE_URL=${CMS_DATABASE_URL}
|
- DATABASE_URL=${CMS_DATABASE_URL}
|
||||||
- MYSQL_DATABASE_URL=${MYSQL_DATABASE_URL}
|
- MYSQL_DATABASE_URL=${MYSQL_DATABASE_URL}
|
||||||
- SAM_DIAGNOSTICO_DATABASE_URL=${SAM_DIAGNOSTICO_DATABASE_URL}
|
- SAM_DIAGNOSTICO_DATABASE_URL=${SAM_DIAGNOSTICO_DATABASE_URL}
|
||||||
|
- WHISPER_URL=${PROD_WHISPER_URL:-https://whisper.t-800.norteamericano.cl}
|
||||||
- LMS_INTERNAL_URL=http://experience:3002
|
- LMS_INTERNAL_URL=http://experience:3002
|
||||||
- NEXT_PUBLIC_LMS_API_URL=${NEXT_PUBLIC_LMS_API_URL}
|
- NEXT_PUBLIC_LMS_API_URL=${NEXT_PUBLIC_LMS_API_URL}
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
+4
-4
@@ -18,10 +18,10 @@ location /cms-api/ {
|
|||||||
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
|
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
|
||||||
proxy_set_header Connection "";
|
proxy_set_header Connection "";
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_connect_timeout 120s;
|
proxy_connect_timeout 300s;
|
||||||
proxy_send_timeout 3600s;
|
proxy_send_timeout 7200s;
|
||||||
proxy_read_timeout 3600s;
|
proxy_read_timeout 7200s;
|
||||||
send_timeout 3600s;
|
send_timeout 7200s;
|
||||||
}
|
}
|
||||||
|
|
||||||
location /lms-api/ {
|
location /lms-api/ {
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
-- Add unit_number to assets: tracks which syllabus unit a file belongs to within a ZIP
|
||||||
|
ALTER TABLE assets ADD COLUMN IF NOT EXISTS unit_number INTEGER;
|
||||||
|
|
||||||
|
-- Link question_bank RAG chunks to their source audio/video asset
|
||||||
|
-- This allows test creation (AI + manual) to attach the audio to exercises
|
||||||
|
ALTER TABLE question_bank ADD COLUMN IF NOT EXISTS source_asset_id UUID REFERENCES assets(id) ON DELETE SET NULL;
|
||||||
|
ALTER TABLE question_bank ADD COLUMN IF NOT EXISTS unit_number INTEGER;
|
||||||
|
|
||||||
|
-- Index for fast lookup by unit
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_assets_unit_number ON assets(organization_id, sam_plan_id, unit_number);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_qb_source_asset ON question_bank(source_asset_id) WHERE source_asset_id IS NOT NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_qb_unit_number ON question_bank(organization_id, unit_number) WHERE unit_number IS NOT NULL;
|
||||||
@@ -16,6 +16,7 @@ use serde::{Deserialize, Serialize};
|
|||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
use std::collections::HashMap;
|
||||||
use std::env;
|
use std::env;
|
||||||
use std::path::Path as StdPath;
|
use std::path::Path as StdPath;
|
||||||
use tokio::process::Command;
|
use tokio::process::Command;
|
||||||
@@ -585,6 +586,9 @@ pub async fn ingest_asset_for_rag(
|
|||||||
&client,
|
&client,
|
||||||
&ollama_url,
|
&ollama_url,
|
||||||
&model,
|
&model,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
asset.unit_number,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -601,6 +605,38 @@ pub async fn ingest_asset_for_rag(
|
|||||||
/// - file: ZIP requerido
|
/// - file: ZIP requerido
|
||||||
/// - course_id: UUID opcional
|
/// - course_id: UUID opcional
|
||||||
/// - ingest_rag: true/false opcional (default false)
|
/// - ingest_rag: true/false opcional (default false)
|
||||||
|
/// Extracts a unit number from a ZIP entry path using the top-level folder name.
|
||||||
|
/// Supports: "Unit 1/...", "Unidad 1/...", "unit-01/...", "01/...", "1/..."
|
||||||
|
fn extract_unit_number(entry_name: &str) -> Option<i32> {
|
||||||
|
let parts: Vec<&str> = entry_name.splitn(2, '/').collect();
|
||||||
|
if parts.len() < 2 {
|
||||||
|
return None; // file at ZIP root — no unit folder
|
||||||
|
}
|
||||||
|
let folder = parts[0].trim();
|
||||||
|
if folder.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let lower = folder.to_lowercase();
|
||||||
|
// Strip common textual prefixes, then parse leading digits
|
||||||
|
let stripped = lower
|
||||||
|
.trim_start_matches("unidad")
|
||||||
|
.trim_start_matches("unit")
|
||||||
|
.trim_start_matches('u')
|
||||||
|
.trim_start_matches(|c: char| !c.is_ascii_digit());
|
||||||
|
let digits: String = stripped.chars().take_while(|c| c.is_ascii_digit()).collect();
|
||||||
|
digits.parse().ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ZipEntryData {
|
||||||
|
entry_name: String,
|
||||||
|
safe_filename: String,
|
||||||
|
content: Vec<u8>,
|
||||||
|
unit_number: Option<i32>,
|
||||||
|
guessed_mimetype: String,
|
||||||
|
is_audio_video: bool,
|
||||||
|
is_flv: bool,
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn import_assets_zip(
|
pub async fn import_assets_zip(
|
||||||
Org(org_ctx): Org,
|
Org(org_ctx): Org,
|
||||||
claims: Claims,
|
claims: Claims,
|
||||||
@@ -613,6 +649,9 @@ pub async fn import_assets_zip(
|
|||||||
let mut sam_plan_id: Option<i32> = None;
|
let mut sam_plan_id: Option<i32> = None;
|
||||||
let mut sam_course_id: Option<i32> = None;
|
let mut sam_course_id: Option<i32> = None;
|
||||||
let mut ingest_rag = false;
|
let mut ingest_rag = false;
|
||||||
|
let mut split_to_regular = false;
|
||||||
|
let mut sam_course_id_r1: Option<i32> = None;
|
||||||
|
let mut sam_course_id_r2: Option<i32> = None;
|
||||||
|
|
||||||
while let Some(mut field) = multipart
|
while let Some(mut field) = multipart
|
||||||
.next_field()
|
.next_field()
|
||||||
@@ -678,6 +717,23 @@ pub async fn import_assets_zip(
|
|||||||
sam_course_id = Some(id);
|
sam_course_id = Some(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if name == "split_to_regular" {
|
||||||
|
if let Ok(txt) = field.text().await {
|
||||||
|
let v = txt.trim().to_lowercase();
|
||||||
|
split_to_regular = v == "1" || v == "true" || v == "yes";
|
||||||
|
}
|
||||||
|
} else if name == "sam_course_id_r1" {
|
||||||
|
if let Ok(txt) = field.text().await {
|
||||||
|
if let Ok(id) = txt.trim().parse::<i32>() {
|
||||||
|
sam_course_id_r1 = Some(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if name == "sam_course_id_r2" {
|
||||||
|
if let Ok(txt) = field.text().await {
|
||||||
|
if let Ok(id) = txt.trim().parse::<i32>() {
|
||||||
|
sam_course_id_r2 = Some(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -699,11 +755,84 @@ pub async fn import_assets_zip(
|
|||||||
return Err((StatusCode::BAD_REQUEST, "No ZIP file uploaded".to_string()));
|
return Err((StatusCode::BAD_REQUEST, "No ZIP file uploaded".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Phase 1: collect all ZIP entries into memory ──────────────────────────
|
||||||
|
let mut all_entries: Vec<ZipEntryData> = Vec::new();
|
||||||
|
let mut unit_set: std::collections::BTreeSet<i32> = Default::default();
|
||||||
|
|
||||||
|
let len = archive.len();
|
||||||
|
for i in 0..len {
|
||||||
|
let mut file = archive
|
||||||
|
.by_index(i)
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("ZIP read error: {}", e)))?;
|
||||||
|
|
||||||
|
if !file.is_file() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let entry_name = file.name().to_string();
|
||||||
|
if entry_name.starts_with("__MACOSX/") || entry_name.ends_with(".DS_Store") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let safe_filename = StdPath::new(&entry_name)
|
||||||
|
.file_name()
|
||||||
|
.and_then(|s| s.to_str())
|
||||||
|
.unwrap_or("unnamed")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let mut content = Vec::new();
|
||||||
|
std::io::Read::read_to_end(&mut file, &mut content)
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("ZIP entry read failed: {}", e)))?;
|
||||||
|
|
||||||
|
let guessed_mimetype = mime_guess::from_path(&safe_filename)
|
||||||
|
.first_or_octet_stream()
|
||||||
|
.to_string();
|
||||||
|
let is_flv = is_flv_media(&safe_filename, &guessed_mimetype);
|
||||||
|
let is_audio_video = is_flv
|
||||||
|
|| guessed_mimetype.starts_with("audio/")
|
||||||
|
|| guessed_mimetype.starts_with("video/");
|
||||||
|
|
||||||
|
let unit_number = extract_unit_number(&entry_name);
|
||||||
|
if let Some(u) = unit_number {
|
||||||
|
unit_set.insert(u);
|
||||||
|
}
|
||||||
|
|
||||||
|
all_entries.push(ZipEntryData {
|
||||||
|
entry_name,
|
||||||
|
safe_filename,
|
||||||
|
content,
|
||||||
|
unit_number,
|
||||||
|
guessed_mimetype,
|
||||||
|
is_audio_video,
|
||||||
|
is_flv,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Phase 1b: calculate split midpoint (intensive → 2 regular courses) ───
|
||||||
|
// For 8-10 units: first half → regular 1, second half → regular 2.
|
||||||
|
// Mid is the last unit number that goes to regular 1 (ceiling of N/2).
|
||||||
|
let split_midpoint: Option<i32> = if split_to_regular
|
||||||
|
&& sam_course_id_r1.is_some()
|
||||||
|
&& sam_course_id_r2.is_some()
|
||||||
|
&& !unit_set.is_empty()
|
||||||
|
{
|
||||||
|
let units: Vec<i32> = unit_set.iter().cloned().collect();
|
||||||
|
let mid_idx = (units.len() + 1) / 2; // ceiling: 8 → 4, 9 → 5, 10 → 5
|
||||||
|
Some(units[mid_idx - 1])
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
// Sort: audio/video first so their asset IDs are known when text is ingested
|
||||||
|
all_entries.sort_by_key(|e| if e.is_audio_video { 0usize } else { 1 });
|
||||||
|
|
||||||
|
// ── Phase 2: process entries ───────────────────────────────────────────────
|
||||||
let mut imported_assets = 0usize;
|
let mut imported_assets = 0usize;
|
||||||
let mut rag_ingested_assets = 0usize;
|
let mut rag_ingested_assets = 0usize;
|
||||||
let mut rag_chunks_ingested = 0usize;
|
let mut rag_chunks_ingested = 0usize;
|
||||||
let mut failed_entries: Vec<String> = Vec::new();
|
let mut failed_entries: Vec<String> = Vec::new();
|
||||||
|
|
||||||
|
// unit_number → (asset_id, public_url): populated from audio/video assets
|
||||||
|
let mut unit_audio_map: HashMap<i32, (Uuid, String)> = HashMap::new();
|
||||||
|
|
||||||
let rag_client = if ingest_rag {
|
let rag_client = if ingest_rag {
|
||||||
Some(
|
Some(
|
||||||
reqwest::Client::builder()
|
reqwest::Client::builder()
|
||||||
@@ -718,41 +847,28 @@ pub async fn import_assets_zip(
|
|||||||
let ollama_url = ai::get_ollama_url();
|
let ollama_url = ai::get_ollama_url();
|
||||||
let model = ai::get_embedding_model();
|
let model = ai::get_embedding_model();
|
||||||
|
|
||||||
let len = archive.len();
|
for entry in all_entries {
|
||||||
for i in 0..len {
|
let ZipEntryData {
|
||||||
let (entry_name, safe_filename, content): (String, String, Vec<u8>) = {
|
entry_name,
|
||||||
let mut file = archive
|
safe_filename,
|
||||||
.by_index(i)
|
content,
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("ZIP read error: {}", e)))?;
|
unit_number,
|
||||||
|
guessed_mimetype,
|
||||||
|
is_audio_video,
|
||||||
|
is_flv,
|
||||||
|
} = entry;
|
||||||
|
|
||||||
if !file.is_file() {
|
// Determine effective sam_course_id based on split midpoint
|
||||||
continue;
|
let effective_sam_course_id = match (split_midpoint, unit_number) {
|
||||||
|
(Some(mid), Some(u)) => {
|
||||||
|
if u <= mid { sam_course_id_r1 } else { sam_course_id_r2 }
|
||||||
}
|
}
|
||||||
|
_ => sam_course_id,
|
||||||
let entry_name = file.name().to_string();
|
|
||||||
if entry_name.starts_with("__MACOSX/") || entry_name.ends_with(".DS_Store") {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let safe_filename = std::path::Path::new(&entry_name)
|
|
||||||
.file_name()
|
|
||||||
.and_then(|s| s.to_str())
|
|
||||||
.unwrap_or("unnamed")
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
let mut content = Vec::new();
|
|
||||||
std::io::Read::read_to_end(&mut file, &mut content)
|
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("ZIP entry read failed: {}", e)))?;
|
|
||||||
|
|
||||||
(entry_name, safe_filename, content)
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let asset_id = Uuid::new_v4();
|
let asset_id = Uuid::new_v4();
|
||||||
let guessed_mimetype = mime_guess::from_path(&safe_filename)
|
|
||||||
.first_or_octet_stream()
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
let (storage_path, stored_filename, mimetype) = if is_flv_media(&safe_filename, &guessed_mimetype) {
|
let (storage_path, stored_filename, mimetype) = if is_flv {
|
||||||
let temp_storage_filename = format!("{}.flv", asset_id);
|
let temp_storage_filename = format!("{}.flv", asset_id);
|
||||||
let temp_storage_path = format!("uploads/{}", temp_storage_filename);
|
let temp_storage_path = format!("uploads/{}", temp_storage_filename);
|
||||||
tokio::fs::create_dir_all("uploads")
|
tokio::fs::create_dir_all("uploads")
|
||||||
@@ -801,7 +917,7 @@ pub async fn import_assets_zip(
|
|||||||
.unwrap_or("")
|
.unwrap_or("")
|
||||||
.to_string();
|
.to_string();
|
||||||
|
|
||||||
let (db_storage_path, _asset_url) = if !storage_filename_for_s3.is_empty() {
|
let (db_storage_path, asset_public_url) = if !storage_filename_for_s3.is_empty() {
|
||||||
if let Some((s3_path, public_url)) = maybe_push_local_file_to_s3(
|
if let Some((s3_path, public_url)) = maybe_push_local_file_to_s3(
|
||||||
&storage_path,
|
&storage_path,
|
||||||
&storage_filename_for_s3,
|
&storage_filename_for_s3,
|
||||||
@@ -834,8 +950,8 @@ pub async fn import_assets_zip(
|
|||||||
|
|
||||||
let insert_result = sqlx::query(
|
let insert_result = sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO assets (id, organization_id, uploaded_by, course_id, english_level, sam_plan_id, sam_course_id, filename, storage_path, mimetype, size_bytes)
|
INSERT INTO assets (id, organization_id, uploaded_by, course_id, english_level, sam_plan_id, sam_course_id, unit_number, filename, storage_path, mimetype, size_bytes)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(asset_id)
|
.bind(asset_id)
|
||||||
@@ -844,7 +960,8 @@ pub async fn import_assets_zip(
|
|||||||
.bind(course_id)
|
.bind(course_id)
|
||||||
.bind(&english_level)
|
.bind(&english_level)
|
||||||
.bind(sam_plan_id)
|
.bind(sam_plan_id)
|
||||||
.bind(sam_course_id)
|
.bind(effective_sam_course_id)
|
||||||
|
.bind(unit_number)
|
||||||
.bind(&stored_filename)
|
.bind(&stored_filename)
|
||||||
.bind(&db_storage_path)
|
.bind(&db_storage_path)
|
||||||
.bind(&mimetype)
|
.bind(&mimetype)
|
||||||
@@ -859,6 +976,13 @@ pub async fn import_assets_zip(
|
|||||||
|
|
||||||
imported_assets += 1;
|
imported_assets += 1;
|
||||||
|
|
||||||
|
// Track audio/video asset per unit for cross-linking with text RAG chunks
|
||||||
|
if is_audio_video {
|
||||||
|
if let Some(u) = unit_number {
|
||||||
|
unit_audio_map.entry(u).or_insert((asset_id, asset_public_url.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if ingest_rag {
|
if ingest_rag {
|
||||||
let asset = Asset {
|
let asset = Asset {
|
||||||
id: asset_id,
|
id: asset_id,
|
||||||
@@ -867,7 +991,8 @@ pub async fn import_assets_zip(
|
|||||||
course_id,
|
course_id,
|
||||||
english_level: english_level.clone(),
|
english_level: english_level.clone(),
|
||||||
sam_plan_id,
|
sam_plan_id,
|
||||||
sam_course_id,
|
sam_course_id: effective_sam_course_id,
|
||||||
|
unit_number,
|
||||||
filename: stored_filename.clone(),
|
filename: stored_filename.clone(),
|
||||||
storage_path: db_storage_path.clone(),
|
storage_path: db_storage_path.clone(),
|
||||||
mimetype: mimetype.clone(),
|
mimetype: mimetype.clone(),
|
||||||
@@ -875,6 +1000,16 @@ pub async fn import_assets_zip(
|
|||||||
created_at: chrono::Utc::now(),
|
created_at: chrono::Utc::now(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// For text/PDF entries, look up the audio asset from the same unit
|
||||||
|
let (linked_audio_id, linked_audio_url) = if !is_audio_video {
|
||||||
|
match unit_number.and_then(|u| unit_audio_map.get(&u)) {
|
||||||
|
Some((aid, aurl)) => (Some(*aid), Some(aurl.clone())),
|
||||||
|
None => (None, None),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
(None, None)
|
||||||
|
};
|
||||||
|
|
||||||
match extract_asset_text(&asset).await {
|
match extract_asset_text(&asset).await {
|
||||||
Ok(extracted) => {
|
Ok(extracted) => {
|
||||||
let trimmed = extracted.trim();
|
let trimmed = extracted.trim();
|
||||||
@@ -889,7 +1024,7 @@ pub async fn import_assets_zip(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let source_kind = if mimetype.starts_with("audio/") || mimetype.starts_with("video/") {
|
let source_kind = if is_audio_video {
|
||||||
"audio-transcription"
|
"audio-transcription"
|
||||||
} else if mimetype.contains("pdf") {
|
} else if mimetype.contains("pdf") {
|
||||||
"pdf"
|
"pdf"
|
||||||
@@ -897,7 +1032,7 @@ pub async fn import_assets_zip(
|
|||||||
"text"
|
"text"
|
||||||
};
|
};
|
||||||
|
|
||||||
let skill = if mimetype.starts_with("audio/") || mimetype.starts_with("video/") {
|
let skill = if is_audio_video {
|
||||||
Some("listening")
|
Some("listening")
|
||||||
} else {
|
} else {
|
||||||
Some("reading")
|
Some("reading")
|
||||||
@@ -915,6 +1050,9 @@ pub async fn import_assets_zip(
|
|||||||
client,
|
client,
|
||||||
&ollama_url,
|
&ollama_url,
|
||||||
&model,
|
&model,
|
||||||
|
linked_audio_id,
|
||||||
|
linked_audio_url,
|
||||||
|
unit_number,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
@@ -1007,6 +1145,9 @@ async fn ingest_chunks_to_question_bank(
|
|||||||
client: &reqwest::Client,
|
client: &reqwest::Client,
|
||||||
ollama_url: &str,
|
ollama_url: &str,
|
||||||
model: &str,
|
model: &str,
|
||||||
|
source_asset_id: Option<Uuid>,
|
||||||
|
audio_url: Option<String>,
|
||||||
|
unit_number: Option<i32>,
|
||||||
) -> Result<(), (StatusCode, String)> {
|
) -> Result<(), (StatusCode, String)> {
|
||||||
for (idx, chunk) in chunks.iter().enumerate() {
|
for (idx, chunk) in chunks.iter().enumerate() {
|
||||||
let metadata = json!({
|
let metadata = json!({
|
||||||
@@ -1017,6 +1158,7 @@ async fn ingest_chunks_to_question_bank(
|
|||||||
"source_kind": source_kind,
|
"source_kind": source_kind,
|
||||||
"chunk_index": idx + 1,
|
"chunk_index": idx + 1,
|
||||||
"chunk_total": chunks.len(),
|
"chunk_total": chunks.len(),
|
||||||
|
"unit_number": unit_number,
|
||||||
});
|
});
|
||||||
|
|
||||||
let inserted_id: Uuid = sqlx::query_scalar(
|
let inserted_id: Uuid = sqlx::query_scalar(
|
||||||
@@ -1031,10 +1173,13 @@ async fn ingest_chunks_to_question_bank(
|
|||||||
skill_assessed,
|
skill_assessed,
|
||||||
source,
|
source,
|
||||||
source_metadata,
|
source_metadata,
|
||||||
|
source_asset_id,
|
||||||
|
audio_url,
|
||||||
|
unit_number,
|
||||||
is_active,
|
is_active,
|
||||||
is_archived
|
is_archived
|
||||||
)
|
)
|
||||||
VALUES ($1, $2, $3, 'short-answer', $4, 'medium', $5, 'imported-material', $6, true, false)
|
VALUES ($1, $2, $3, 'short-answer', $4, 'medium', $5, 'imported-material', $6, $7, $8, $9, true, false)
|
||||||
RETURNING id
|
RETURNING id
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
@@ -1044,6 +1189,9 @@ async fn ingest_chunks_to_question_bank(
|
|||||||
.bind("RAG material chunk from uploaded asset")
|
.bind("RAG material chunk from uploaded asset")
|
||||||
.bind(skill)
|
.bind(skill)
|
||||||
.bind(&metadata)
|
.bind(&metadata)
|
||||||
|
.bind(source_asset_id)
|
||||||
|
.bind(&audio_url)
|
||||||
|
.bind(unit_number)
|
||||||
.fetch_one(pool)
|
.fetch_one(pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Insert failed: {}", e)))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Insert failed: {}", e)))?;
|
||||||
@@ -1139,54 +1287,81 @@ async fn extract_pdf_text_from_bytes(bytes: Vec<u8>) -> Result<String, (StatusCo
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn transcribe_media_bytes(file_data: Vec<u8>, filename: &str) -> Result<String, (StatusCode, String)> {
|
async fn transcribe_media_bytes(file_data: Vec<u8>, filename: &str) -> Result<String, (StatusCode, String)> {
|
||||||
let whisper_url = std::env::var("WHISPER_URL")
|
let mut whisper_urls: Vec<String> = Vec::new();
|
||||||
.unwrap_or_else(|_| "http://localhost:8000".to_string());
|
if let Ok(url) = std::env::var("WHISPER_URL") {
|
||||||
let client = reqwest::Client::new();
|
let trimmed = url.trim();
|
||||||
|
if !trimmed.is_empty() {
|
||||||
let form = reqwest::multipart::Form::new()
|
whisper_urls.push(trimmed.trim_end_matches('/').to_string());
|
||||||
.part(
|
}
|
||||||
"file",
|
|
||||||
reqwest::multipart::Part::bytes(file_data).file_name(filename.to_string()),
|
|
||||||
)
|
|
||||||
.text("model", "whisper-1")
|
|
||||||
.text("response_format", "json");
|
|
||||||
|
|
||||||
let response = client
|
|
||||||
.post(format!("{}/v1/audio/transcriptions", whisper_url))
|
|
||||||
.multipart(form)
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Whisper request failed: {}", e)))?;
|
|
||||||
|
|
||||||
if !response.status().is_success() {
|
|
||||||
let status = response.status();
|
|
||||||
let body = response.text().await.unwrap_or_default();
|
|
||||||
return Err((
|
|
||||||
StatusCode::BAD_GATEWAY,
|
|
||||||
format!("Whisper API error {}: {}", status, body),
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let transcription: serde_json::Value = response
|
// Container-friendly fallbacks for common local deployments.
|
||||||
.json()
|
if whisper_urls.is_empty() {
|
||||||
.await
|
whisper_urls.push("http://host.docker.internal:8000".to_string());
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Invalid Whisper response: {}", e)))?;
|
whisper_urls.push("http://localhost:8000".to_string());
|
||||||
|
|
||||||
let text = transcription
|
|
||||||
.get("text")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("")
|
|
||||||
.trim()
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
if text.is_empty() {
|
|
||||||
return Err((
|
|
||||||
StatusCode::BAD_REQUEST,
|
|
||||||
"Whisper no pudo extraer texto del audio/video".to_string(),
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(text)
|
let client = reqwest::Client::builder()
|
||||||
|
.timeout(std::time::Duration::from_secs(300))
|
||||||
|
.build()
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Whisper HTTP client error: {}", e)))?;
|
||||||
|
|
||||||
|
let mut last_error = String::new();
|
||||||
|
|
||||||
|
for base_url in whisper_urls {
|
||||||
|
let form = reqwest::multipart::Form::new()
|
||||||
|
.part(
|
||||||
|
"file",
|
||||||
|
reqwest::multipart::Part::bytes(file_data.clone()).file_name(filename.to_string()),
|
||||||
|
)
|
||||||
|
.text("model", "whisper-1")
|
||||||
|
.text("response_format", "json");
|
||||||
|
|
||||||
|
let endpoint = format!("{}/v1/audio/transcriptions", base_url);
|
||||||
|
let response = match client.post(&endpoint).multipart(form).send().await {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(e) => {
|
||||||
|
last_error = format!("{} ({})", endpoint, e);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
let status = response.status();
|
||||||
|
let body = response.text().await.unwrap_or_default();
|
||||||
|
last_error = format!("{} -> {}: {}", endpoint, status, body);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let transcription: serde_json::Value = response
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Invalid Whisper response: {}", e)))?;
|
||||||
|
|
||||||
|
let text = transcription
|
||||||
|
.get("text")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.trim()
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
if text.is_empty() {
|
||||||
|
return Err((
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"Whisper no pudo extraer texto del audio/video".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
Err((
|
||||||
|
StatusCode::BAD_GATEWAY,
|
||||||
|
format!(
|
||||||
|
"Whisper request failed en todos los endpoints configurados. Ultimo error: {}",
|
||||||
|
last_error
|
||||||
|
),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn chunk_text(text: &str, max_chars: usize) -> Vec<String> {
|
fn chunk_text(text: &str, max_chars: usize) -> Vec<String> {
|
||||||
|
|||||||
@@ -864,7 +864,7 @@ pub async fn get_mysql_plans(
|
|||||||
State(pool): State<PgPool>,
|
State(pool): State<PgPool>,
|
||||||
) -> Result<Json<Vec<MySqlPlanInfo>>, (StatusCode, String)> {
|
) -> Result<Json<Vec<MySqlPlanInfo>>, (StatusCode, String)> {
|
||||||
// Read from SAM mirror in PostgreSQL with SAM-native fields.
|
// Read from SAM mirror in PostgreSQL with SAM-native fields.
|
||||||
let plans: Vec<MySqlPlanInfo> = sqlx::query_as(
|
let mut plans: Vec<MySqlPlanInfo> = sqlx::query_as(
|
||||||
r#"
|
r#"
|
||||||
SELECT
|
SELECT
|
||||||
idPlanDeEstudios AS id_plan_de_estudios,
|
idPlanDeEstudios AS id_plan_de_estudios,
|
||||||
@@ -879,6 +879,111 @@ pub async fn get_mysql_plans(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch plans: {}", e)))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch plans: {}", e)))?;
|
||||||
|
|
||||||
|
// Backward-compatible fallback: if SAM mirror is empty, use legacy metadata mirror.
|
||||||
|
if plans.is_empty() {
|
||||||
|
plans = sqlx::query_as(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
mysql_id AS id_plan_de_estudios,
|
||||||
|
name AS nombre_plan
|
||||||
|
FROM mysql_study_plans
|
||||||
|
WHERE organization_id = $1 AND is_active = TRUE
|
||||||
|
ORDER BY name
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(org_ctx.id)
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch legacy plans: {}", e)))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last-resort auto-sync: if still empty, pull metadata from MySQL and persist it.
|
||||||
|
if plans.is_empty() {
|
||||||
|
match connect_mysql_pool("MYSQL_DATABASE_URL").await {
|
||||||
|
Ok(mysql_pool) => {
|
||||||
|
let mysql_plans: Result<Vec<MySqlPlanInfo>, sqlx::Error> = sqlx::query_as(
|
||||||
|
r#"
|
||||||
|
SELECT DISTINCT
|
||||||
|
pe.idPlanDeEstudios AS id_plan_de_estudios,
|
||||||
|
pe.Nombre AS nombre_plan
|
||||||
|
FROM plandeestudios pe
|
||||||
|
WHERE pe.Activo = 1
|
||||||
|
ORDER BY pe.Nombre
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.fetch_all(&mysql_pool)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let mysql_courses: Result<Vec<MySqlCourseInfo>, sqlx::Error> = sqlx::query_as(
|
||||||
|
r#"
|
||||||
|
SELECT DISTINCT
|
||||||
|
c.idCursos AS id_cursos,
|
||||||
|
c.NombreCurso AS nombre_curso,
|
||||||
|
c.NivelCurso AS nivel_curso,
|
||||||
|
pe.idPlanDeEstudios AS id_plan_de_estudios,
|
||||||
|
pe.Nombre AS nombre_plan,
|
||||||
|
c.Duracion AS duracion
|
||||||
|
FROM curso c
|
||||||
|
JOIN plandeestudios pe ON c.idPlanDeEstudios = pe.idPlanDeEstudios
|
||||||
|
WHERE c.Activo = 1
|
||||||
|
AND pe.Activo = 1
|
||||||
|
ORDER BY pe.Nombre, c.NivelCurso
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.fetch_all(&mysql_pool)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match (mysql_plans, mysql_courses) {
|
||||||
|
(Ok(p), Ok(c)) => {
|
||||||
|
if let Err(err) = save_mysql_courses_and_plans(&pool, org_ctx.id, p, c).await {
|
||||||
|
tracing::warn!("Auto-sync MySQL metadata failed: {}", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(Err(e), _) => tracing::warn!("Auto-sync plans query failed: {}", e),
|
||||||
|
(_, Err(e)) => tracing::warn!("Auto-sync courses query failed: {}", e),
|
||||||
|
}
|
||||||
|
|
||||||
|
mysql_pool.close().await;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("Auto-sync could not connect to MySQL: {:?}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reload plans after auto-sync attempt.
|
||||||
|
plans = sqlx::query_as(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
idPlanDeEstudios AS id_plan_de_estudios,
|
||||||
|
Nombre AS nombre_plan
|
||||||
|
FROM sam_study_plans
|
||||||
|
WHERE organization_id = $1 AND Activo = TRUE
|
||||||
|
ORDER BY Nombre
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(org_ctx.id)
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
if plans.is_empty() {
|
||||||
|
plans = sqlx::query_as(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
mysql_id AS id_plan_de_estudios,
|
||||||
|
name AS nombre_plan
|
||||||
|
FROM mysql_study_plans
|
||||||
|
WHERE organization_id = $1 AND is_active = TRUE
|
||||||
|
ORDER BY name
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(org_ctx.id)
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Ok(Json(plans))
|
Ok(Json(plans))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -889,7 +994,7 @@ pub async fn get_mysql_courses_by_plan(
|
|||||||
Query(filters): Query<MySqlCoursesFilters>,
|
Query(filters): Query<MySqlCoursesFilters>,
|
||||||
) -> Result<Json<Vec<MySqlCourseInfo>>, (StatusCode, String)> {
|
) -> Result<Json<Vec<MySqlCourseInfo>>, (StatusCode, String)> {
|
||||||
// Read from SAM mirror in PostgreSQL with SAM-native fields.
|
// Read from SAM mirror in PostgreSQL with SAM-native fields.
|
||||||
let courses: Vec<MySqlCourseInfo> = sqlx::query_as(
|
let mut courses: Vec<MySqlCourseInfo> = sqlx::query_as(
|
||||||
r#"
|
r#"
|
||||||
SELECT
|
SELECT
|
||||||
c.idCursos AS id_cursos,
|
c.idCursos AS id_cursos,
|
||||||
@@ -915,6 +1020,33 @@ pub async fn get_mysql_courses_by_plan(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch courses: {}", e)))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch courses: {}", e)))?;
|
||||||
|
|
||||||
|
// Backward-compatible fallback: if SAM mirror is empty, use legacy metadata mirror.
|
||||||
|
if courses.is_empty() {
|
||||||
|
courses = sqlx::query_as(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
c.mysql_id AS id_cursos,
|
||||||
|
c.name AS nombre_curso,
|
||||||
|
c.level AS nivel_curso,
|
||||||
|
sp.mysql_id AS id_plan_de_estudios,
|
||||||
|
sp.name AS nombre_plan,
|
||||||
|
c.duracion::double precision AS duracion
|
||||||
|
FROM mysql_courses c
|
||||||
|
JOIN mysql_study_plans sp ON c.study_plan_id = sp.id
|
||||||
|
WHERE c.organization_id = $1
|
||||||
|
AND c.is_active = TRUE
|
||||||
|
AND sp.is_active = TRUE
|
||||||
|
AND sp.mysql_id = $2
|
||||||
|
ORDER BY c.level
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(org_ctx.id)
|
||||||
|
.bind(filters.plan_id)
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch legacy courses: {}", e)))?;
|
||||||
|
}
|
||||||
|
|
||||||
Ok(Json(courses))
|
Ok(Json(courses))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -347,6 +347,7 @@ pub struct Asset {
|
|||||||
pub english_level: Option<String>,
|
pub english_level: Option<String>,
|
||||||
pub sam_plan_id: Option<i32>,
|
pub sam_plan_id: Option<i32>,
|
||||||
pub sam_course_id: Option<i32>,
|
pub sam_course_id: Option<i32>,
|
||||||
|
pub unit_number: Option<i32>,
|
||||||
pub filename: String,
|
pub filename: String,
|
||||||
pub storage_path: String,
|
pub storage_path: String,
|
||||||
pub mimetype: String,
|
pub mimetype: String,
|
||||||
@@ -1402,6 +1403,8 @@ pub struct QuestionBank {
|
|||||||
pub updated_at: chrono::DateTime<chrono::Utc>,
|
pub updated_at: chrono::DateTime<chrono::Utc>,
|
||||||
pub embedding: Option<String>, // PGVector embedding for semantic search
|
pub embedding: Option<String>, // PGVector embedding for semantic search
|
||||||
pub embedding_updated_at: Option<chrono::DateTime<chrono::Utc>>,
|
pub embedding_updated_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||||
|
pub source_asset_id: Option<Uuid>, // audio/video asset that originated this RAG chunk
|
||||||
|
pub unit_number: Option<i32>, // syllabus unit number from ZIP folder structure
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
|
|||||||
@@ -37,8 +37,13 @@ FROM node:20-slim AS runner
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
ENV NODE_ENV production
|
ENV NODE_ENV production
|
||||||
|
|
||||||
# Install system dependencies for Rust binary
|
# Install system dependencies for Rust binary and asset processing
|
||||||
RUN apt-get update && apt-get install -y openssl ca-certificates && rm -rf /var/lib/apt/lists/*
|
RUN apt-get update && apt-get install -y \
|
||||||
|
openssl \
|
||||||
|
ca-certificates \
|
||||||
|
ffmpeg \
|
||||||
|
poppler-utils \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Install sharp for Next.js image optimization
|
# Install sharp for Next.js image optimization
|
||||||
RUN --mount=type=cache,target=/root/.npm npm install sharp
|
RUN --mount=type=cache,target=/root/.npm npm install sharp
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import React, { useMemo, useState } from 'react';
|
import React, { useMemo, useState } from 'react';
|
||||||
import { cmsApi, questionBankApi, MySqlPlan, MySqlCourse } from '@/lib/api';
|
import { cmsApi, questionBankApi, MySqlPlan, MySqlCourse } from '@/lib/api';
|
||||||
import { Upload, Database, FileArchive, CheckCircle2, AlertTriangle } from 'lucide-react';
|
import { Upload, Database, FileArchive, CheckCircle2, AlertTriangle, Scissors } from 'lucide-react';
|
||||||
|
|
||||||
export default function AdminSharedMaterialsPage() {
|
export default function AdminSharedMaterialsPage() {
|
||||||
const [zipFile, setZipFile] = useState<File | null>(null);
|
const [zipFile, setZipFile] = useState<File | null>(null);
|
||||||
@@ -12,7 +12,16 @@ export default function AdminSharedMaterialsPage() {
|
|||||||
const [courses, setCourses] = useState<MySqlCourse[]>([]);
|
const [courses, setCourses] = useState<MySqlCourse[]>([]);
|
||||||
const [selectedPlanId, setSelectedPlanId] = useState<number | ''>('');
|
const [selectedPlanId, setSelectedPlanId] = useState<number | ''>('');
|
||||||
const [selectedCourseId, setSelectedCourseId] = useState<number | ''>('');
|
const [selectedCourseId, setSelectedCourseId] = useState<number | ''>('');
|
||||||
|
const [splitToRegular, setSplitToRegular] = useState(false);
|
||||||
|
const [regularPlanId, setRegularPlanId] = useState<number | ''>('');
|
||||||
|
const [regularCourses, setRegularCourses] = useState<MySqlCourse[]>([]);
|
||||||
|
const [selectedCourseIdR1, setSelectedCourseIdR1] = useState<number | ''>('');
|
||||||
|
const [selectedCourseIdR2, setSelectedCourseIdR2] = useState<number | ''>('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [uploadProgress, setUploadProgress] = useState(0);
|
||||||
|
const [phase, setPhase] = useState<'idle' | 'uploading' | 'processing' | 'done' | 'error'>('idle');
|
||||||
|
const [startedAt, setStartedAt] = useState<number | null>(null);
|
||||||
|
const [elapsedSeconds, setElapsedSeconds] = useState(0);
|
||||||
const [result, setResult] = useState<{
|
const [result, setResult] = useState<{
|
||||||
imported_assets: number;
|
imported_assets: number;
|
||||||
rag_ingested_assets: number;
|
rag_ingested_assets: number;
|
||||||
@@ -22,6 +31,32 @@ export default function AdminSharedMaterialsPage() {
|
|||||||
|
|
||||||
const canUpload = useMemo(() => Boolean(zipFile) && !loading, [zipFile, loading]);
|
const canUpload = useMemo(() => Boolean(zipFile) && !loading, [zipFile, loading]);
|
||||||
|
|
||||||
|
// Detect if the selected course is "intensive" (no trailing digit) and auto-detect
|
||||||
|
// the two corresponding regular courses (same name + " 1" and " 2").
|
||||||
|
const selectedCourseName = useMemo(
|
||||||
|
() => courses.find((c) => c.idCursos === selectedCourseId)?.NombreCurso ?? '',
|
||||||
|
[courses, selectedCourseId],
|
||||||
|
);
|
||||||
|
const isIntensiveCourse = useMemo(
|
||||||
|
() => Boolean(selectedCourseId) && !/\s*[12]$/.test(selectedCourseName.trim()),
|
||||||
|
[selectedCourseId, selectedCourseName],
|
||||||
|
);
|
||||||
|
// Suggested regular course names: replace "INTENSIVE" with nothing or trim trailing "INTENSIVE"
|
||||||
|
const regularBaseName = useMemo(() => {
|
||||||
|
const name = selectedCourseName.trim();
|
||||||
|
return name.replace(/\s*INTENSIVE\s*$/i, '').trim();
|
||||||
|
}, [selectedCourseName]);
|
||||||
|
const regularCourse1 = useMemo(
|
||||||
|
() => regularCourses.find((c) => c.NombreCurso.trim() === `${regularBaseName} 1`)
|
||||||
|
?? regularCourses.find((c) => /\s1$/.test(c.NombreCurso.trim())),
|
||||||
|
[regularCourses, regularBaseName],
|
||||||
|
);
|
||||||
|
const regularCourse2 = useMemo(
|
||||||
|
() => regularCourses.find((c) => c.NombreCurso.trim() === `${regularBaseName} 2`)
|
||||||
|
?? regularCourses.find((c) => /\s2$/.test(c.NombreCurso.trim())),
|
||||||
|
[regularCourses, regularBaseName],
|
||||||
|
);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
questionBankApi.getMySQLPlans().then(setPlans).catch(() => setPlans([]));
|
questionBankApi.getMySQLPlans().then(setPlans).catch(() => setPlans([]));
|
||||||
}, []);
|
}, []);
|
||||||
@@ -30,10 +65,72 @@ export default function AdminSharedMaterialsPage() {
|
|||||||
if (!selectedPlanId) {
|
if (!selectedPlanId) {
|
||||||
setCourses([]);
|
setCourses([]);
|
||||||
setSelectedCourseId('');
|
setSelectedCourseId('');
|
||||||
|
setSplitToRegular(false);
|
||||||
|
setRegularPlanId('');
|
||||||
|
setRegularCourses([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
questionBankApi.getMySQLCoursesByPlan(selectedPlanId).then(setCourses).catch(() => setCourses([]));
|
questionBankApi.getMySQLCoursesByPlan(selectedPlanId).then(setCourses).catch(() => setCourses([]));
|
||||||
}, [selectedPlanId]);
|
// Auto-detect sibling regular plan (swap INTENSIVO <-> REGULAR in plan name)
|
||||||
|
const intensivePlan = plans.find((p) => p.idPlanDeEstudios === selectedPlanId);
|
||||||
|
if (intensivePlan) {
|
||||||
|
const regularPlanName = intensivePlan.NombrePlan.replace(/INTENSIVO/i, 'REGULAR').trim();
|
||||||
|
const sibling = plans.find((p) => p.NombrePlan.toUpperCase() === regularPlanName.toUpperCase());
|
||||||
|
if (sibling) {
|
||||||
|
setRegularPlanId(sibling.idPlanDeEstudios);
|
||||||
|
questionBankApi.getMySQLCoursesByPlan(sibling.idPlanDeEstudios).then(setRegularCourses).catch(() => setRegularCourses([]));
|
||||||
|
} else {
|
||||||
|
setRegularPlanId('');
|
||||||
|
setRegularCourses([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [selectedPlanId, plans]);
|
||||||
|
|
||||||
|
// Load courses for manually selected regular plan
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!regularPlanId) return;
|
||||||
|
questionBankApi.getMySQLCoursesByPlan(regularPlanId).then(setRegularCourses).catch(() => setRegularCourses([]));
|
||||||
|
}, [regularPlanId]);
|
||||||
|
|
||||||
|
// Auto-fill regular course IDs when intensive course is selected and split is on
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (splitToRegular && isIntensiveCourse) {
|
||||||
|
setSelectedCourseIdR1(regularCourse1?.idCursos ?? '');
|
||||||
|
setSelectedCourseIdR2(regularCourse2?.idCursos ?? '');
|
||||||
|
}
|
||||||
|
}, [splitToRegular, isIntensiveCourse, regularCourse1, regularCourse2]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!loading || !startedAt) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timer = window.setInterval(() => {
|
||||||
|
const seconds = Math.floor((Date.now() - startedAt) / 1000);
|
||||||
|
setElapsedSeconds(seconds);
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.clearInterval(timer);
|
||||||
|
};
|
||||||
|
}, [loading, startedAt]);
|
||||||
|
|
||||||
|
const formatElapsed = (seconds: number): string => {
|
||||||
|
const mins = Math.floor(seconds / 60);
|
||||||
|
const secs = seconds % 60;
|
||||||
|
return `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusText =
|
||||||
|
phase === 'uploading'
|
||||||
|
? `Subiendo ZIP... ${uploadProgress}%`
|
||||||
|
: phase === 'processing'
|
||||||
|
? 'Procesando contenido en servidor (esto puede tardar varios minutos para ZIPs grandes)...'
|
||||||
|
: phase === 'done'
|
||||||
|
? 'Importacion completada'
|
||||||
|
: phase === 'error'
|
||||||
|
? 'Importacion con error'
|
||||||
|
: 'Sin proceso activo';
|
||||||
|
|
||||||
const handleUpload = async () => {
|
const handleUpload = async () => {
|
||||||
if (!zipFile) {
|
if (!zipFile) {
|
||||||
@@ -43,6 +140,10 @@ export default function AdminSharedMaterialsPage() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
setPhase('uploading');
|
||||||
|
setUploadProgress(0);
|
||||||
|
setStartedAt(Date.now());
|
||||||
|
setElapsedSeconds(0);
|
||||||
setResult(null);
|
setResult(null);
|
||||||
const response = await cmsApi.importAssetsZip(
|
const response = await cmsApi.importAssetsZip(
|
||||||
zipFile,
|
zipFile,
|
||||||
@@ -51,10 +152,19 @@ export default function AdminSharedMaterialsPage() {
|
|||||||
englishLevel || undefined,
|
englishLevel || undefined,
|
||||||
selectedPlanId || undefined,
|
selectedPlanId || undefined,
|
||||||
selectedCourseId || undefined,
|
selectedCourseId || undefined,
|
||||||
|
(pct) => {
|
||||||
|
setUploadProgress(pct);
|
||||||
|
setPhase(pct >= 100 ? 'processing' : 'uploading');
|
||||||
|
},
|
||||||
|
splitToRegular,
|
||||||
|
selectedCourseIdR1 || undefined,
|
||||||
|
selectedCourseIdR2 || undefined,
|
||||||
);
|
);
|
||||||
setResult(response);
|
setResult(response);
|
||||||
|
setPhase('done');
|
||||||
alert('Importacion ZIP finalizada.');
|
alert('Importacion ZIP finalizada.');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
setPhase('error');
|
||||||
console.error('ZIP import failed:', error);
|
console.error('ZIP import failed:', error);
|
||||||
const msg = error instanceof Error ? error.message : 'Error al importar ZIP';
|
const msg = error instanceof Error ? error.message : 'Error al importar ZIP';
|
||||||
alert(msg);
|
alert(msg);
|
||||||
@@ -79,7 +189,11 @@ export default function AdminSharedMaterialsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h2 className="font-bold text-slate-900 dark:text-white">Importar ZIP de Materiales</h2>
|
<h2 className="font-bold text-slate-900 dark:text-white">Importar ZIP de Materiales</h2>
|
||||||
<p className="text-xs text-slate-500 dark:text-gray-500">Se cargan a biblioteca compartida (sin curso especifico).</p>
|
<p className="text-xs text-slate-500 dark:text-gray-500">
|
||||||
|
Organiza el ZIP en carpetas por unidad: <code className="bg-slate-100 px-1 rounded">Unit 1/</code>,{' '}
|
||||||
|
<code className="bg-slate-100 px-1 rounded">Unit 2/</code>, etc. Los audios/videos se vinculan
|
||||||
|
automaticamente a los ejercicios de su unidad.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -115,6 +229,7 @@ export default function AdminSharedMaterialsPage() {
|
|||||||
const value = e.target.value ? Number(e.target.value) : '';
|
const value = e.target.value ? Number(e.target.value) : '';
|
||||||
setSelectedPlanId(value);
|
setSelectedPlanId(value);
|
||||||
setSelectedCourseId('');
|
setSelectedCourseId('');
|
||||||
|
setSplitToRegular(false);
|
||||||
}}
|
}}
|
||||||
className="w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm"
|
className="w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm"
|
||||||
>
|
>
|
||||||
@@ -132,6 +247,7 @@ export default function AdminSharedMaterialsPage() {
|
|||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const value = e.target.value ? Number(e.target.value) : '';
|
const value = e.target.value ? Number(e.target.value) : '';
|
||||||
setSelectedCourseId(value);
|
setSelectedCourseId(value);
|
||||||
|
setSplitToRegular(false);
|
||||||
const selected = courses.find((c) => c.idCursos === value);
|
const selected = courses.find((c) => c.idCursos === value);
|
||||||
if (selected?.NivelCurso !== undefined && selected?.NivelCurso !== null) {
|
if (selected?.NivelCurso !== undefined && selected?.NivelCurso !== null) {
|
||||||
const n = selected.NivelCurso;
|
const n = selected.NivelCurso;
|
||||||
@@ -153,6 +269,88 @@ export default function AdminSharedMaterialsPage() {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Split to regular courses — only shown for intensive courses */}
|
||||||
|
{isIntensiveCourse && (
|
||||||
|
<div className="rounded-lg border border-indigo-200 bg-indigo-50 p-4 space-y-4">
|
||||||
|
<label className="flex items-center gap-3 text-sm text-indigo-900">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={splitToRegular}
|
||||||
|
onChange={(e) => setSplitToRegular(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<Scissors className="w-4 h-4 flex-shrink-0" />
|
||||||
|
<span className="font-medium">
|
||||||
|
Dividir unidades en 2 cursos regulares (intensivo = regular 1 + regular 2)
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<p className="text-xs text-indigo-700 ml-7">
|
||||||
|
Las unidades 1..N/2 van al curso regular 1 y N/2+1..N al regular 2.
|
||||||
|
Para 8-10 unidades esto resulta en 4-5 unidades por curso regular.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{splitToRegular && (
|
||||||
|
<div className="ml-7 space-y-4">
|
||||||
|
{/* Plan regular — may be auto-detected or chosen manually */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="block text-xs font-medium text-indigo-800">
|
||||||
|
Plan de Estudios Regular
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={regularPlanId}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = e.target.value ? Number(e.target.value) : '';
|
||||||
|
setRegularPlanId(v);
|
||||||
|
setSelectedCourseIdR1('');
|
||||||
|
setSelectedCourseIdR2('');
|
||||||
|
}}
|
||||||
|
className="w-full rounded-lg border border-indigo-300 bg-white px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<option value="">Seleccionar plan regular</option>
|
||||||
|
{plans.map((p) => (
|
||||||
|
<option key={p.idPlanDeEstudios} value={p.idPlanDeEstudios}>{p.NombrePlan}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="block text-xs font-medium text-indigo-800">
|
||||||
|
Curso Regular 1 (unidades 1..N/2)
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={selectedCourseIdR1}
|
||||||
|
onChange={(e) => setSelectedCourseIdR1(e.target.value ? Number(e.target.value) : '')}
|
||||||
|
disabled={!regularPlanId}
|
||||||
|
className="w-full rounded-lg border border-indigo-300 bg-white px-3 py-2 text-sm disabled:opacity-60"
|
||||||
|
>
|
||||||
|
<option value="">Seleccionar</option>
|
||||||
|
{regularCourses.map((c) => (
|
||||||
|
<option key={c.idCursos} value={c.idCursos}>{c.NombreCurso}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="block text-xs font-medium text-indigo-800">
|
||||||
|
Curso Regular 2 (unidades N/2+1..N)
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={selectedCourseIdR2}
|
||||||
|
onChange={(e) => setSelectedCourseIdR2(e.target.value ? Number(e.target.value) : '')}
|
||||||
|
disabled={!regularPlanId}
|
||||||
|
className="w-full rounded-lg border border-indigo-300 bg-white px-3 py-2 text-sm disabled:opacity-60"
|
||||||
|
>
|
||||||
|
<option value="">Seleccionar</option>
|
||||||
|
{regularCourses.map((c) => (
|
||||||
|
<option key={c.idCursos} value={c.idCursos}>{c.NombreCurso}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="block text-sm font-medium text-slate-700 dark:text-gray-300">Nivel de Ingles para este ZIP</label>
|
<label className="block text-sm font-medium text-slate-700 dark:text-gray-300">Nivel de Ingles para este ZIP</label>
|
||||||
<select
|
<select
|
||||||
@@ -182,6 +380,27 @@ export default function AdminSharedMaterialsPage() {
|
|||||||
<Upload className="w-4 h-4" />
|
<Upload className="w-4 h-4" />
|
||||||
{loading ? 'Importando...' : 'Importar ZIP Compartido'}
|
{loading ? 'Importando...' : 'Importar ZIP Compartido'}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{(loading || phase === 'done' || phase === 'error') && (
|
||||||
|
<div className="rounded-lg border border-slate-200 bg-slate-50 p-4 space-y-3">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="font-medium text-slate-800">Estado del proceso</span>
|
||||||
|
<span className="text-slate-600">Tiempo: {formatElapsed(elapsedSeconds)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full h-2 rounded-full bg-slate-200 overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-indigo-600 transition-all duration-300"
|
||||||
|
style={{ width: `${phase === 'processing' ? 100 : uploadProgress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm text-slate-700">{statusText}</p>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
Nota: esta importacion ZIP corre en la misma solicitud (no crea fila en Tasks), por eso aqui ves el estado en vivo.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{result && (
|
{result && (
|
||||||
@@ -228,4 +447,4 @@ export default function AdminSharedMaterialsPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -974,6 +974,10 @@ export const cmsApi = {
|
|||||||
englishLevel?: string,
|
englishLevel?: string,
|
||||||
samPlanId?: number,
|
samPlanId?: number,
|
||||||
samCourseId?: number,
|
samCourseId?: number,
|
||||||
|
onProgress?: (pct: number) => void,
|
||||||
|
splitToRegular = false,
|
||||||
|
samCourseIdR1?: number,
|
||||||
|
samCourseIdR2?: number,
|
||||||
): Promise<AssetZipImportResult> => {
|
): Promise<AssetZipImportResult> => {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
@@ -983,6 +987,11 @@ export const cmsApi = {
|
|||||||
if (englishLevel) formData.append('english_level', englishLevel);
|
if (englishLevel) formData.append('english_level', englishLevel);
|
||||||
if (samPlanId) formData.append('sam_plan_id', String(samPlanId));
|
if (samPlanId) formData.append('sam_plan_id', String(samPlanId));
|
||||||
if (samCourseId) formData.append('sam_course_id', String(samCourseId));
|
if (samCourseId) formData.append('sam_course_id', String(samCourseId));
|
||||||
|
if (splitToRegular) {
|
||||||
|
formData.append('split_to_regular', 'true');
|
||||||
|
if (samCourseIdR1) formData.append('sam_course_id_r1', String(samCourseIdR1));
|
||||||
|
if (samCourseIdR2) formData.append('sam_course_id_r2', String(samCourseIdR2));
|
||||||
|
}
|
||||||
|
|
||||||
const xhr = new XMLHttpRequest();
|
const xhr = new XMLHttpRequest();
|
||||||
xhr.open('POST', `${API_BASE_URL}/api/assets/import-zip`);
|
xhr.open('POST', `${API_BASE_URL}/api/assets/import-zip`);
|
||||||
@@ -996,15 +1005,29 @@ export const cmsApi = {
|
|||||||
if (xhr.status >= 200 && xhr.status < 300) {
|
if (xhr.status >= 200 && xhr.status < 300) {
|
||||||
resolve(JSON.parse(xhr.responseText));
|
resolve(JSON.parse(xhr.responseText));
|
||||||
} else {
|
} else {
|
||||||
let msg = 'ZIP import failed';
|
let msg = `ZIP import failed (HTTP ${xhr.status})`;
|
||||||
try {
|
try {
|
||||||
msg = JSON.parse(xhr.responseText).message || msg;
|
const parsed = JSON.parse(xhr.responseText);
|
||||||
} catch { }
|
msg = parsed.message || parsed.error || msg;
|
||||||
|
} catch {
|
||||||
|
const raw = (xhr.responseText || '').trim();
|
||||||
|
if (raw) {
|
||||||
|
const compact = raw.replace(/\s+/g, ' ').slice(0, 240);
|
||||||
|
msg = `${msg}: ${compact}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
reject(new Error(msg));
|
reject(new Error(msg));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
xhr.onerror = () => reject(new Error('Network error'));
|
xhr.onerror = () => reject(new Error('Network error'));
|
||||||
|
if (onProgress) {
|
||||||
|
xhr.upload.onprogress = (event) => {
|
||||||
|
if (!event.lengthComputable) return;
|
||||||
|
const pct = Math.round((event.loaded / event.total) * 100);
|
||||||
|
onProgress(Math.max(0, Math.min(100, pct)));
|
||||||
|
};
|
||||||
|
}
|
||||||
xhr.send(formData);
|
xhr.send(formData);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user