bitrix tasks are created with comments (imported only 1-by-1)

This commit is contained in:
Ivan I. Ovchinnikov 2026-03-25 22:05:38 +03:00
parent d1570b30c4
commit e238c00a30
7 changed files with 643 additions and 81 deletions

1
Cargo.lock generated
View File

@ -1201,6 +1201,7 @@ dependencies = [
"js-sys", "js-sys",
"log", "log",
"mime", "mime",
"mime_guess",
"native-tls", "native-tls",
"once_cell", "once_cell",
"percent-encoding", "percent-encoding",

View File

@ -10,7 +10,7 @@ tower = "0.4"
tower-http = { version = "0.5", features = ["fs", "trace", "cors"] } tower-http = { version = "0.5", features = ["fs", "trace", "cors"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
reqwest = { version = "0.11", features = ["json"] } reqwest = { version = "0.11", features = ["json", "multipart"] }
dotenvy = "0.15" dotenvy = "0.15"
tracing = "0.1" tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] }

View File

@ -7,6 +7,48 @@ use axum::response::{Html, IntoResponse};
use serde::Deserialize; use serde::Deserialize;
use tower_sessions::Session; use tower_sessions::Session;
fn deserialize_task_ids<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::de::{self, Visitor};
use std::fmt;
struct TaskIdsVisitor;
impl<'de> Visitor<'de> for TaskIdsVisitor {
type Value = Vec<String>;
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
formatter.write_str("a string or sequence of strings")
}
fn visit_str<E>(self, value: &str) -> Result<Vec<String>, E>
where
E: de::Error,
{
if value.is_empty() {
Ok(Vec::new())
} else {
Ok(vec![value.to_string()])
}
}
fn visit_seq<A>(self, mut seq: A) -> Result<Vec<String>, A::Error>
where
A: de::SeqAccess<'de>,
{
let mut task_ids = Vec::new();
while let Some(id) = seq.next_element::<String>()? {
task_ids.push(id);
}
Ok(task_ids)
}
}
deserializer.deserialize_any(TaskIdsVisitor)
}
#[derive(Template)] #[derive(Template)]
#[template(path = "import_select.html")] #[template(path = "import_select.html")]
pub struct ImportSelectTemplate { pub struct ImportSelectTemplate {
@ -34,10 +76,14 @@ pub struct ImportResult {
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct ImportForm { pub struct ImportForm {
pub project_id: String, pub project_id: String,
#[serde(deserialize_with = "deserialize_task_ids")]
pub task_ids: Vec<String>, pub task_ids: Vec<String>,
pub import_comments: Option<String>,
} }
pub async fn import_select(session: Session) -> impl IntoResponse { pub async fn import_select(session: Session) -> impl IntoResponse {
tracing::info!("📥 Запрос страницы импорта");
let settings = match session let settings = match session
.get::<UserSettings>("user_settings") .get::<UserSettings>("user_settings")
.await .await
@ -136,6 +182,17 @@ pub async fn import_select(session: Session) -> impl IntoResponse {
} }
pub async fn import_tasks(session: Session, Form(form): Form<ImportForm>) -> impl IntoResponse { pub async fn import_tasks(session: Session, Form(form): Form<ImportForm>) -> impl IntoResponse {
eprintln!("========================================");
eprintln!("=== НАЧАЛО ИМПОРТА ===");
eprintln!("========================================");
tracing::info!("========================================");
tracing::info!("=== НАЧАЛО ИМПОРТА ===");
tracing::info!("========================================");
tracing::info!("form.project_id = {}", form.project_id);
tracing::info!("form.task_ids = {:?}", form.task_ids);
tracing::info!("form.import_comments = {:?}", form.import_comments);
let settings = match session let settings = match session
.get::<UserSettings>("user_settings") .get::<UserSettings>("user_settings")
.await .await
@ -143,10 +200,19 @@ pub async fn import_tasks(session: Session, Form(form): Form<ImportForm>) -> imp
{ {
Some(s) => s, Some(s) => s,
None => { None => {
eprintln!("❌ ОШИБКА: Настройки не найдены");
return Html("<div class='error'>Сначала настройте подключение</div>".to_string()); return Html("<div class='error'>Сначала настройте подключение</div>".to_string());
} }
}; };
let bitrix_user_id = bitrix24::extract_user_id_from_webhook(&settings.bitrix_webhook)
.unwrap_or(settings.redmine_user_id);
tracing::info!("Bitrix user ID: {}", bitrix_user_id);
let import_comments = true;
tracing::info!("Импорт комментариев: {} (принудительно)", import_comments);
let project_id: u32 = match form.project_id.parse() { let project_id: u32 = match form.project_id.parse() {
Ok(id) => id, Ok(id) => id,
Err(_) => { Err(_) => {
@ -173,14 +239,31 @@ pub async fn import_tasks(session: Session, Form(form): Form<ImportForm>) -> imp
} }
}; };
tracing::info!("----------------------------------------");
tracing::info!("Обработка задачи Redmine #{}", task_id);
eprintln!("📋 Обработка задачи Redmine #{}", task_id);
match redmine::fetch_issue_full(&settings.redmine_api_key, &settings.redmine_url, task_id) match redmine::fetch_issue_full(&settings.redmine_api_key, &settings.redmine_url, task_id)
.await .await
{ {
Ok(issue) => { Ok(issue) => {
let attachments_count = issue.attachments.as_ref().map(|a| a.len()).unwrap_or(0);
let journals_count = issue.journals.as_ref().map(|j| j.len()).unwrap_or(0);
tracing::info!(
"Задача получена: вложений={}, журналов={}",
attachments_count,
journals_count
);
eprintln!(
" Вложений: {}, Журналов: {}",
attachments_count, journals_count
);
let description = format!( let description = format!(
"<p><strong>Импортировано из Redmine #{}:</strong></p>{}", "<p><strong>Импортировано из Redmine #{}:</strong></p>{}",
issue.id, issue.id,
issue.description.unwrap_or_default() issue.description.clone().unwrap_or_default()
); );
match bitrix24::create_task( match bitrix24::create_task(
@ -188,13 +271,137 @@ pub async fn import_tasks(session: Session, Form(form): Form<ImportForm>) -> imp
&settings.bitrix_webhook, &settings.bitrix_webhook,
&issue.subject, &issue.subject,
&description, &description,
settings.redmine_user_id, bitrix_user_id,
project_id, project_id,
issue.due_date.as_deref(), issue.due_date.as_deref(),
) )
.await .await
{ {
Ok(bitrix_task_id) => { Ok(bitrix_task_id) => {
tracing::info!("✅ Задача Bitrix24 #{} создана", bitrix_task_id);
eprintln!(" ✅ Задача Bitrix24 #{} создана", bitrix_task_id);
if import_comments {
eprintln!(" 📋 Начало импорта комментариев...");
let mut file_doc_ids: Vec<String> = Vec::new();
if let Some(attachments) = &issue.attachments {
eprintln!(" 📎 Вложений: {}", attachments.len());
for attachment in attachments {
if let Some(content_url) = &attachment.content_url {
eprintln!(" Загрузка: {}", attachment.filename);
match redmine::download_attachment(
content_url,
&settings.redmine_api_key,
)
.await
{
Ok(file_content) => {
eprintln!(
" Скачано: {} байт",
file_content.len()
);
match bitrix24::upload_attachment(
&settings.bitrix_url,
&settings.bitrix_webhook,
&attachment.filename,
&file_content,
)
.await
{
Ok(file_doc_id) => {
eprintln!(
" ✅ Загружено с ID: {}",
file_doc_id
);
file_doc_ids.push(file_doc_id);
}
Err(e) => {
eprintln!(" ❌ Ошибка: {}", e);
tracing::error!("Ошибка загрузки: {}", e);
}
}
}
Err(e) => {
eprintln!("Не удалось скачать: {}", e);
}
}
}
}
}
if let Some(desc) = &issue.description {
if !desc.is_empty() {
eprintln!(" 💬 Добавляем описание...");
match bitrix24::add_comment_with_attachments(
&settings.bitrix_url,
&settings.bitrix_webhook,
bitrix_task_id,
desc,
file_doc_ids.clone(),
bitrix_user_id,
None, // Убрали дату
)
.await
{
Ok(_) => {
eprintln!(" ✅ Описание добавлено");
tracing::info!("Описание добавлено");
}
Err(e) => {
eprintln!(" ❌ Ошибка: {}", e);
tracing::error!("Ошибка описания: {}", e);
}
}
}
}
if let Some(journals) = &issue.journals {
eprintln!(" 📝 Журналов: {}", journals.len());
for journal in journals {
if let Some(notes) = &journal.notes {
if !notes.is_empty() {
let user_name = journal
.user
.as_ref()
.map(|u| u.name.as_str())
.unwrap_or("Система");
eprintln!(" Комментарий от {}...", user_name);
match bitrix24::add_comment(
&settings.bitrix_url,
&settings.bitrix_webhook,
bitrix_task_id,
notes, // Просто текст, без HTML
bitrix_user_id,
None, // Убрали дату
)
.await
{
Ok(_) => {
eprintln!(" ✅ Добавлен");
tracing::info!("Комментарий добавлен");
}
Err(e) => {
eprintln!(" ❌ Ошибка: {}", e);
tracing::error!("Ошибка комментария: {}", e);
}
}
}
}
}
}
eprintln!(" 📋 Импорт завершён");
}
success_count += 1; success_count += 1;
results.push(ImportResult { results.push(ImportResult {
task_id: issue.id, task_id: issue.id,
@ -204,6 +411,7 @@ pub async fn import_tasks(session: Session, Form(form): Form<ImportForm>) -> imp
}); });
} }
Err(e) => { Err(e) => {
eprintln!(" ❌ Ошибка: {}", e);
error_count += 1; error_count += 1;
results.push(ImportResult { results.push(ImportResult {
task_id: issue.id, task_id: issue.id,
@ -215,17 +423,22 @@ pub async fn import_tasks(session: Session, Form(form): Form<ImportForm>) -> imp
} }
} }
Err(e) => { Err(e) => {
eprintln!(" ❌ Ошибка: {}", e);
error_count += 1; error_count += 1;
results.push(ImportResult { results.push(ImportResult {
task_id, task_id,
task_title: "Неизвестно".to_string(), task_title: "Неизвестно".to_string(),
bitrix_id: None, bitrix_id: None,
error: Some(format!("Ошибка получения задачи: {}", e)), error: Some(format!("Ошибка: {}", e)),
}); });
} }
} }
} }
eprintln!("========================================");
eprintln!("Успешно: {}, Ошибок: {}", success_count, error_count);
eprintln!("========================================");
let template = ImportResultTemplate { let template = ImportResultTemplate {
success_count, success_count,
error_count, error_count,

View File

@ -15,12 +15,18 @@ pub mod services;
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {
// ИНИЦИАЛИЗАЦИЯ ЛОГИРОВАНИЯ - ОБЯЗАТЕЛЬНО!
tracing_subscriber::registry() tracing_subscriber::registry()
.with(tracing_subscriber::EnvFilter::new( .with(
std::env::var("RUST_LOG").unwrap_or_else(|_| "info".into()), tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()),
)) )
.with(tracing_subscriber::fmt::layer())
.init(); .init();
tracing::info!("========================================");
tracing::info!("=== ЗАПУСК BITMINE ===");
tracing::info!("========================================");
dotenvy::dotenv().ok(); dotenvy::dotenv().ok();
let session_store = MemoryStore::default(); let session_store = MemoryStore::default();
@ -50,8 +56,11 @@ async fn main() {
); );
let addr = SocketAddr::from(([127, 0, 0, 1], 3000)); let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
tracing::info!("Listening on {}", addr); tracing::info!("🚀 Сервер запускается на {}", addr);
let listener = TcpListener::bind(addr).await.unwrap(); let listener = TcpListener::bind(addr).await.unwrap();
tracing::info!("✅ Сервер готов к работе");
tracing::info!("========================================");
axum::serve(listener, app).await.unwrap(); axum::serve(listener, app).await.unwrap();
} }

View File

@ -1,6 +1,7 @@
use crate::models::BitrixTask; use crate::models::BitrixTask;
use reqwest; use reqwest;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashSet;
#[derive(Debug, Deserialize, Serialize, Clone)] #[derive(Debug, Deserialize, Serialize, Clone)]
pub struct BitrixPortalInfo { pub struct BitrixPortalInfo {
@ -43,19 +44,14 @@ struct BitrixApiGroup {
name: String, name: String,
} }
#[derive(Debug, Deserialize, Serialize, Clone)] pub fn extract_user_id_from_webhook(webhook: &str) -> Option<u32> {
struct BitrixCreateTaskResponse { let parts: Vec<&str> = webhook.split('/').collect();
result: Option<BitrixCreateTaskResult>, for (i, part) in parts.iter().enumerate() {
} if *part == "rest" && i + 1 < parts.len() {
return parts[i + 1].parse::<u32>().ok();
#[derive(Debug, Deserialize, Serialize, Clone)] }
struct BitrixCreateTaskResult { }
task: Option<BitrixCreateTaskInfo>, None
}
#[derive(Debug, Deserialize, Serialize, Clone)]
struct BitrixCreateTaskInfo {
id: Option<u32>,
} }
pub async fn test_connection( pub async fn test_connection(
@ -169,30 +165,94 @@ pub async fn fetch_projects(
webhook.trim_matches('/') webhook.trim_matches('/')
); );
let response = client.get(&full_url).query(&[("MY", "Y")]).send().await?; let response = client.get(&full_url).query(&[("MY", "Y")]).send().await;
if !response.status().is_success() { if let Ok(resp) = response {
return Err(format!("Bitrix24 API error: {}", response.status()).into()); if resp.status().is_success() {
} let body: serde_json::Value = resp.json().await.unwrap_or(serde_json::json!({}));
let body: serde_json::Value = response.json().await?; if let Some(result) = body.get("result").and_then(|r| r.as_array()) {
let projects: Vec<BitrixProject> = result
if let Some(error) = body.get("error") { .iter()
return Err(format!("Bitrix24 error: {:?}", error).into()); .filter_map(|g| {
} let id = g.get("ID").and_then(|i| i.as_u64()).map(|i| i as u32)?;
let name = g
let groups_response: BitrixGroupsResponse = serde_json::from_value(body)?; .get("NAME")
.and_then(|n| n.as_str())
let projects: Vec<BitrixProject> = groups_response .unwrap_or("Без названия")
.result .to_string();
.into_iter() Some(BitrixProject { id, name })
.map(|g| BitrixProject {
id: g.id,
name: g.name,
}) })
.collect(); .collect();
Ok(projects) if !projects.is_empty() {
tracing::info!("Найдено проектов: {}", projects.len());
return Ok(projects);
}
}
}
}
tracing::info!("socialnetwork_group.get не доступен, пробуем альтернативу...");
let full_url = format!(
"{}/{}/tasks.task.list.json",
url.trim_end_matches('/'),
webhook.trim_matches('/')
);
let response = client
.post(&full_url)
.json(&serde_json::json!({
"filter": {},
"select": ["ID", "TITLE", "GROUP_ID"],
"limit": 100
}))
.send()
.await;
if let Ok(resp) = response {
if resp.status().is_success() {
let body: serde_json::Value = resp.json().await.unwrap_or(serde_json::json!({}));
if let Some(tasks) = body
.get("result")
.and_then(|r| r.get("tasks"))
.and_then(|t| t.as_array())
{
let mut projects: Vec<BitrixProject> = Vec::new();
let mut seen_ids: HashSet<u32> = HashSet::new();
for task in tasks {
if let Some(group_id) = task.get("GROUP_ID").and_then(|g| g.as_u64()) {
if group_id > 0 && !seen_ids.contains(&(group_id as u32)) {
seen_ids.insert(group_id as u32);
projects.push(BitrixProject {
id: group_id as u32,
name: format!("Проект #{}", group_id),
});
}
}
}
if !projects.is_empty() {
tracing::info!("Найдено проектов из задач: {}", projects.len());
projects.push(BitrixProject {
id: 0,
name: "Без проекта (личные задачи)".to_string(),
});
return Ok(projects);
}
}
}
}
tracing::warn!("Не удалось получить проекты, возвращаем только 'Без проекта'");
Ok(vec![BitrixProject {
id: 0,
name: "Без проекта (личные задачи)".to_string(),
}])
} }
pub async fn create_task( pub async fn create_task(
@ -212,38 +272,49 @@ pub async fn create_task(
webhook.trim_matches('/') webhook.trim_matches('/')
); );
let mut task_data = serde_json::json!({ let mut fields = serde_json::Map::new();
"TITLE": title, fields.insert("TITLE".to_string(), serde_json::json!(title));
"DESCRIPTION": description, fields.insert("DESCRIPTION".to_string(), serde_json::json!(description));
"RESPONSIBLE_ID": responsible_id, fields.insert(
"TASK_CONTROL": false, "RESPONSIBLE_ID".to_string(),
"ALLOW_CHANGE_DEADLINE": true, serde_json::json!(responsible_id),
}); );
fields.insert("ALLOW_CHANGE_DEADLINE".to_string(), serde_json::json!(true));
fields.insert("TAGS".to_string(), serde_json::json!("Redmine"));
if project_id > 0 { if project_id > 0 {
task_data["GROUP_ID"] = serde_json::json!(vec![project_id]); fields.insert("GROUP_ID".to_string(), serde_json::json!(project_id));
} }
if let Some(dl) = deadline { if let Some(dl) = deadline {
if !dl.is_empty() { if !dl.is_empty() {
task_data["DEADLINE"] = serde_json::json!(dl); let formatted = if dl.contains('T') {
dl.to_string()
} else {
format!("{}T23:59:59+03:00", dl)
};
fields.insert("DEADLINE".to_string(), serde_json::json!(formatted));
} }
} }
let response = client let request_body = serde_json::json!({
.post(&full_url) "fields": fields
.json(&serde_json::json!({ });
"FIELDS": task_data,
"REGISTER_NOTIFICATION_ON_ADD": "N"
}))
.send()
.await?;
if !response.status().is_success() { tracing::info!("Bitrix24 create_task request: {:?}", request_body);
return Err(format!("Bitrix24 API error: {}", response.status()).into());
let response = client.post(&full_url).json(&request_body).send().await?;
let status = response.status();
let body_text = response.text().await?;
tracing::info!("Bitrix24 create_task response: {} - {}", status, body_text);
if !status.is_success() {
return Err(format!("Bitrix24 API error: {} - {}", status, body_text).into());
} }
let body: serde_json::Value = response.json().await?; let body: serde_json::Value = serde_json::from_str(&body_text)?;
if let Some(error) = body.get("error") { if let Some(error) = body.get("error") {
return Err(format!("Bitrix24 error: {:?}", error).into()); return Err(format!("Bitrix24 error: {:?}", error).into());
@ -251,11 +322,182 @@ pub async fn create_task(
let task_id = body let task_id = body
.get("result") .get("result")
.and_then(|r| r.get("TASK")) .and_then(|r| r.get("task"))
.and_then(|t| t.get("ID")) .and_then(|t| t.get("id"))
.and_then(|i| i.as_u64()) .and_then(|i| {
.map(|i| i as u32) i.as_str()
.ok_or("Не удалось получить ID созданной задачи")?; .and_then(|s| s.parse::<u32>().ok())
.or_else(|| i.as_u64().map(|n| n as u32))
})
.ok_or_else(|| format!("Не удалось получить ID задачи из ответа: {}", body_text))?;
tracing::info!("Создана задача #{}", task_id);
Ok(task_id) Ok(task_id)
} }
// ИСПРАВЛЕНО: TASKID передаётся как query параметр
pub async fn add_comment(
url: &str,
webhook: &str,
task_id: u32,
comment_text: &str,
author_id: u32,
_created_on: Option<&str>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let client = reqwest::Client::new();
// TASKID в URL как query параметр
let full_url = format!(
"{}/{}/task.commentitem.add?taskId={}",
url.trim_end_matches('/'),
webhook.trim_matches('/'),
task_id
);
let request_body = serde_json::json!({
"FIELDS": {
"POST_MESSAGE": comment_text,
"AUTHOR_ID": author_id
}
});
tracing::info!("Bitrix24 add_comment request: {:?}", request_body);
let response = client.post(&full_url).json(&request_body).send().await?;
let status = response.status();
let body_text = response.text().await?;
tracing::info!("Bitrix24 add_comment response: {} - {}", status, body_text);
if !status.is_success() {
return Err(format!("Bitrix24 comment error: {} - {}", status, body_text).into());
}
let body: serde_json::Value = serde_json::from_str(&body_text)?;
if let Some(error) = body.get("error") {
return Err(format!("Bitrix24 comment error: {:?}", error).into());
}
tracing::info!("Комментарий добавлен к задаче #{}", task_id);
Ok(())
}
// Загрузка вложения
pub async fn upload_attachment(
url: &str,
webhook: &str,
file_name: &str,
file_content: &[u8],
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
let client = reqwest::Client::new();
let upload_url = format!(
"{}/{}/disk.file.upload.json",
url.trim_end_matches('/'),
webhook.trim_matches('/')
);
let file_part =
reqwest::multipart::Part::bytes(file_content.to_vec()).file_name(file_name.to_string());
let form = reqwest::multipart::Form::new()
.part("file", file_part)
.text("name", file_name.to_string());
let upload_response = client.post(&upload_url).multipart(form).send().await?;
let upload_body: serde_json::Value = upload_response.json().await?;
tracing::info!("Bitrix24 disk.file.upload response: {:?}", upload_body);
if let Some(error) = upload_body.get("error") {
tracing::warn!("Failed to upload file to Bitrix24 disk: {:?}", error);
return Err(format!("Failed to upload file: {:?}", error).into());
}
let file_id = upload_body
.get("result")
.and_then(|r| r.get("id"))
.and_then(|i| i.as_u64())
.ok_or("Не удалось получить ID файла")?;
let file_doc_id = format!("n{}", file_id);
tracing::info!("Файл {} загружен с ID: {}", file_name, file_doc_id);
Ok(file_doc_id)
}
// ИСПРАВЛЕНО: TASKID передаётся как query параметр
pub async fn add_comment_with_attachments(
url: &str,
webhook: &str,
task_id: u32,
comment_text: &str,
file_doc_ids: Vec<String>,
author_id: u32,
_created_on: Option<&str>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let client = reqwest::Client::new();
// TASKID в URL как query параметр
let full_url = format!(
"{}/{}/task.commentitem.add?taskId={}",
url.trim_end_matches('/'),
webhook.trim_matches('/'),
task_id
);
let mut fields = serde_json::Map::new();
fields.insert("POST_MESSAGE".to_string(), serde_json::json!(comment_text));
fields.insert("AUTHOR_ID".to_string(), serde_json::json!(author_id));
if !file_doc_ids.is_empty() {
fields.insert(
"UF_FORUM_MESSAGE_DOC".to_string(),
serde_json::json!(file_doc_ids),
);
}
let request_body = serde_json::json!({
"FIELDS": fields
});
tracing::info!(
"Bitrix24 add_comment_with_attachments request: {:?}",
request_body
);
let response = client.post(&full_url).json(&request_body).send().await?;
let status = response.status();
let body_text = response.text().await?;
tracing::info!(
"Bitrix24 add_comment_with_attachments response: {} - {}",
status,
body_text
);
if !status.is_success() {
return Err(format!("Bitrix24 comment error: {} - {}", status, body_text).into());
}
let body: serde_json::Value = serde_json::from_str(&body_text)?;
if let Some(error) = body.get("error") {
return Err(format!("Bitrix24 comment error: {:?}", error).into());
}
tracing::info!(
"Комментарий с {} вложениями добавлен к задаче #{}",
file_doc_ids.len(),
task_id
);
Ok(())
}

View File

@ -163,3 +163,57 @@ pub async fn test_connection(
let issues = fetch_user_issues(api_key, url, user_id).await?; let issues = fetch_user_issues(api_key, url, user_id).await?;
Ok(issues.len()) Ok(issues.len())
} }
pub async fn download_attachment(
content_url: &str,
api_key: &str,
) -> Result<Vec<u8>, Box<dyn std::error::Error + Send + Sync>> {
let client = reqwest::Client::new();
let response = client
.get(content_url)
.header("X-Redmine-API-Key", api_key)
.send()
.await?;
if !response.status().is_success() {
return Err(format!("Failed to download attachment: {}", response.status()).into());
}
let bytes = response.bytes().await?;
Ok(bytes.to_vec())
}
pub async fn get_attachment_info(
api_key: &str,
url: &str,
attachment_id: u32,
) -> Result<RedmineAttachment, Box<dyn std::error::Error + Send + Sync>> {
let client = reqwest::Client::new();
let full_url = format!(
"{}/attachments/{}.json",
url.trim_end_matches('/'),
attachment_id
);
let response = client
.get(&full_url)
.header("X-Redmine-API-Key", api_key)
.send()
.await?;
if !response.status().is_success() {
return Err(format!("Failed to get attachment info: {}", response.status()).into());
}
let body: serde_json::Value = response.json().await?;
let attachment: RedmineAttachment = serde_json::from_value(
body.get("attachment")
.ok_or("No attachment in response")?
.clone(),
)?;
Ok(attachment)
}

View File

@ -74,6 +74,7 @@
color: #007bff; color: #007bff;
text-decoration: none; text-decoration: none;
} }
.back-link:hover { text-decoration: underline; }
.select-all { .select-all {
margin-bottom: 16px; margin-bottom: 16px;
} }
@ -81,6 +82,11 @@
color: #666; color: #666;
margin-left: 8px; margin-left: 8px;
} }
.help-text {
font-size: 12px;
color: #666;
margin-top: 4px;
}
</style> </style>
</head> </head>
<body> <body>
@ -103,6 +109,20 @@
<option value="{{ project.id }}">{{ project.name }}</option> <option value="{{ project.id }}">{{ project.name }}</option>
{% endfor %} {% endfor %}
</select> </select>
<div class="help-text" style="margin-top: 12px; background: #fff3e0; padding: 12px; border-radius: 6px; border-left: 4px solid #f57c00;">
<strong>⚠️ Не видите свой проект?</strong> Введите ID вручную:<br>
<input type="number" id="project_id_manual" name="project_id_manual" placeholder="Например: 105"
style="width: 150px; margin-top: 8px; padding: 8px; border: 1px solid #ddd; border-radius: 4px;"
onchange="document.getElementById('project_id').value = this.value">
<div style="margin-top: 8px; font-size: 12px; color: #666;">
<strong>📋 Как узнать ID проекта:</strong><br>
1. Откройте Bitrix24 → Задачи и Проекты<br>
2. Откройте нужный проект<br>
3. В URL будет <code>group/XXX/</code> или <code>project/XXX/</code> — это ID
</div>
</div>
</div> </div>
</div> </div>
@ -152,30 +172,53 @@
</div> </div>
<div class="card"> <div class="card">
<h3>📋 Опции импорта</h3>
<div style="margin: 16px 0;">
<label style="display: flex; align-items: center; gap: 8px; cursor: pointer;">
<input type="checkbox" name="import_comments" id="import_comments" value="on" checked>
<div>
<strong>Импортировать комментарии и вложения</strong>
<div style="font-size: 12px; color: #666; margin-top: 4px;">
Все примечания, история и файлы из Redmine будут добавлены в Bitrix24
</div>
</div>
</label>
</div>
<button type="submit" class="btn btn-success">🚀 Импортировать выбранные задачи</button> <button type="submit" class="btn btn-success">🚀 Импортировать выбранные задачи</button>
<a href="/api/redmine/tasks" class="btn btn-secondary">Отмена</a> <a href="/api/redmine/tasks" class="btn btn-secondary">Отмена</a>
</div> </div>
</form> </form>
</div> </div>
<script> <script>
async function submitImport(event) { function toggleAll(checkbox) {
event.preventDefault(); const checkboxes = document.querySelectorAll('input[name="task_ids"]');
const formData = new FormData(event.target); checkboxes.forEach(cb => {
const data = { cb.checked = checkbox.checked;
project_id: formData.get('project_id'), });
task_ids: Array.from(formData.getAll('task_ids')) }
};
const response = await fetch('/import', { document.addEventListener('DOMContentLoaded', function() {
method: 'POST', const manualInput = document.getElementById('project_id_manual');
headers: { 'Content-Type': 'application/json' }, const selectInput = document.getElementById('project_id');
body: JSON.stringify(data)
if (manualInput && selectInput) {
manualInput.addEventListener('input', function() {
if (this.value) {
selectInput.value = this.value;
}
}); });
window.location.href = '/import/result'; selectInput.addEventListener('change', function() {
} if (this.value && this.value !== '0') {
</script> manualInput.value = this.value;
}
});
}
});
</script>
<style> <style>
.status-new { background: #e3f2fd; color: #1976d2; } .status-new { background: #e3f2fd; color: #1976d2; }