目前组内成员都喜欢用 XMIND 写测试用例,但是 XMIND 文件无法正常上传到 JIRA 里面,XRAY 插件不支持 XMIND 文件格式,必须要转为 CSV 文件格式才可以进行上传。所以,为了解决这个痛点,也希望能实现一键上传 XMIND 文件,并自动转为 CSV 文件上传到 JIRA 里面去,就需要开发一个转换工具。
修复和更新:
增加了 JOB ID 任务的最终执行后的状态确认,如果任务执行后,最终是导入失败,会给出错误提示信息。


添加指派人功能,允许用户直接在网页端手动输入指派人邮箱
新增前置条件功能,允许选填


# !/usr/bin/python
# -*- coding: utf-8 -*-
"""
@File : JiraTokenFetcher.py
@Create Time: 2025/5/28 10:38
@Description: Get jira X-Acpt value or JMT value
"""
import json
import logging
import os
import requests
from seleniumwire import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.chrome.service import Service
from selenium.webdriver.chrome.options import Options
import time
# 配置日志记录
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
# 定义 token 存储文件的路径
TOKEN_STORE_FILE = r'C:\XMindToJiraConverterTool\jiraToken\jira_token_store.txt'
class TokenFetcher:
def __init__(self, chrome_driver_path):
self.chrome_driver_path = chrome_driver_path
self.seleniumwire_options = {'proxy': {}}
self.chrome_options = Options()
self.chrome_options.add_experimental_option("detach", False) # 不保持浏览器窗口打开
# self.chrome_options.add_argument("--headless") # 启用 headless 模式
self.chrome_options.add_argument("--disable-gpu") # 禁用 GPU 加速(某些情况下需要)
self.chrome_options.add_argument("--no-sandbox") # 禁用沙盒模式(某些情况下需要)
self.chrome_options.add_argument("--disable-dev-shm-usage") # 禁用 /dev/shm 的使用(某些情况下需要)
def load_token_from_file(self):
"""从文件加载 token"""
if not os.path.exists(TOKEN_STORE_FILE):
logging.info("Token 文件不存在,需要获取新的 token")
return None
try:
with open(TOKEN_STORE_FILE, 'r') as file:
token_data = json.load(file)
logging.info(f"已加载 token, token: {token_data}")
return token_data.get('token')
except Exception as e:
logging.error(f"加载 token 文件失败: {e}")
return None
def save_token_to_file(self, token):
"""
将 token 保存到文件
:param token:
:return:
"""
if not token:
logging.error("Token为空,无法保存")
return
# 确保文件夹存在
os.makedirs(os.path.dirname(TOKEN_STORE_FILE), exist_ok=True)
try:
with open(TOKEN_STORE_FILE, 'w') as file:
json.dump({'token': token}, file)
logging.info(f"Token 已保存到文件: {TOKEN_STORE_FILE}")
except Exception as e:
logging.error(f"保存 token 文件失败: {e}")
def validate_token(self, token):
"""
获取有效的 token,优先使用现有 token
:param token:
:return:
"""
if not token:
return False
try:
# 验证 token 是否可以通过验证接口
validate_url = "https://us.xray.cloud.getxray.app/api/internal/test-repository/get-tests"
headers = {
'X-Acpt': token,
'Accept': 'application/json, text/plain, */*',
'Accept-Encoding': 'gzip, deflate, br, zstd',
'Accept-Language': 'zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7',
'Content-Type': 'application/json;charset=UTF-8',
'Origin': 'https://us.xray.cloud.getxray.app',
'Host': 'us.xray.cloud.getxray.app'
}
payload = {
"folderIds": [
"6528f6753506ca9792cba495"
],
"projectId": "10650"
}
response = requests.post(validate_url, headers=headers, json=payload)
if response.status_code == 200:
logging.info("Token 验证成功")
return True
else:
logging.warning(f"Token 验证失败,状态码: {response.status_code}")
return False
except Exception as e:
logging.error(f"Token 验证过程中发生错误: {e}")
return False
def get_valid_token(self):
"""
获取有效的 token,优先使用现有 token
:return:
"""
# 尝试从文件加载 token
token = self.load_token_from_file()
# 如果 token 存在且有效,直接返回
if token and self.validate_token(token):
logging.info("使用现有的有效 token")
return token
# 如果现有 token 无效或不存在,重新获取 token
logging.info("现有的 token 无效或不存在,正在获取新的 token")
new_token = self.get_token(max_retries=3)
# 如果成功获取新 token,保存并返回
if new_token:
self.save_token_to_file(new_token)
return new_token
# 如果获取新 token 失败,返回 None
return None
def wait_for_element(self, driver, by, value, timeout=300):
""" 等待元素出现 """
try:
return WebDriverWait(driver, timeout).until(EC.presence_of_element_located((by, value)))
except Exception as e:
logging.error(f"元素 {value} 出现超时: {e}")
raise
def click_element(self, driver, by, value, timeout=10):
""" 点击元素 """
try:
element = WebDriverWait(driver, timeout).until(EC.element_to_be_clickable((by, value)))
element.click()
except Exception as e:
logging.error(f"点击元素 {value} 失败: {e}")
raise
def get_token(self, max_retries=3):
token = None
driver = None
retries = 0
while retries < max_retries:
try:
# 创建 Chrome 驱动
driver = webdriver.Chrome(
service=Service(self.chrome_driver_path),
options=self.chrome_options,
seleniumwire_options=self.seleniumwire_options
)
# 设置浏览器窗口全屏
driver.maximize_window()
logging.info(">>>>>>>>>>>>>>>>>>>>>> 浏览器窗口已设置为全屏 <<<<<<<<<<<<<<<<<<<<<<<")
# 打开网页
driver.get('https://kone.atlassian.net/projects/QR247?selectedItem=com.atlassian.plugins.atlassian-connect-plugin:com.xpandit.plugins.xray__testing-board')
logging.info(">>>>>>>>>>>>>>>>>>>>>> 页面已打开 <<<<<<<<<<<<<<<<<<<<<<<")
# 输入用户名
try:
username_input = self.wait_for_element(driver, By.ID, 'username-uid1')
except:
username_input = self.wait_for_element(driver, By.CSS_SELECTOR, '[data-testid="username"]')
username_input.send_keys('guodong.ge@kone.com')
logging.info(">>>>>>>>>>>>>>>>>>>>>> 用户名已输入 <<<<<<<<<<<<<<<<<<<<<<<")
# 点击继续按钮
self.click_element(driver, By.XPATH, '//span[text()="Continue"]')
logging.info(">>>>>>>>>>>>>>>>>>>>>> 继续按钮已点击 <<<<<<<<<<<<<<<<<<<<<<<")
# 等待页面加载完成并检查是否找到 Testing Board 按钮
testing_board_button = self.wait_for_element(driver, By.CSS_SELECTOR,
'h2[data-testid="navigation-kit-ui-tab.ui.link-tab.non-interactive-tab"][aria-current="page"] span')
if testing_board_button.text == "Testing Board":
logging.info("-------------------- Testing Board 按钮已找到 --------------------")
else:
logging.error("-------------------- Testing Board 按钮未找到 --------------------")
time.sleep(2)
retries += 1
if retries < max_retries:
logging.info(f"重试获取token,剩余尝试次数: {max_retries - retries}")
continue
else:
logging.error("达到最大重试次数,未能获取到有效的token")
return token
# 遍历所有请求,查找包含 X-Acpt 或 JWT 的请求
for request in driver.requests:
logging.debug(f"Request URL: {request.url}")
logging.debug(f"Request headers: {request.headers}")
if 'x-acpt' in request.headers and request.headers['x-acpt']:
token = request.headers['x-acpt']
logging.info(f"获取到 X-Acpt Token: {token}")
break
elif 'jwt' in request.url:
token = request.url.split('=')[-1]
logging.info(f"获取到 JWT Token: {token}")
break
if not token:
logging.error("获取token失败")
retries += 1
if retries < max_retries:
logging.info(f"重试获取token,剩余尝试次数: {max_retries - retries}")
continue
else:
logging.error("达到最大重试次数,未能获取到有效的token")
return token
# 如果成功获取token,退出循环
break
except Exception as e:
logging.error(f"获取token过程中发生错误: {str(e)}")
retries += 1
if retries < max_retries:
logging.info(f"重试获取token,剩余尝试次数: {max_retries - retries}")
else:
logging.error("达到最大重试次数,未能获取到有效的token")
return token
finally:
# 关闭浏览器
if driver:
driver.quit()
logging.info("-------------------- 浏览器已关闭 --------------------")
return token
if __name__ == "__main__":
fetcher = TokenFetcher(chrome_driver_path = r"C:/Program Files/Google/Chrome/Application/chromedriver.exe")
# 验证从文件加载Token功能
loaded_token = fetcher.load_token_from_file()
if loaded_token:
logging.info("从文件加载Token功能验证成功")
# 验证加载的Token是否有效
is_valid = fetcher.validate_token(loaded_token)
if is_valid:
logging.info("加载的Token有效且在有效期内")
else:
logging.warning("加载的Token无效或已过期")
else:
logging.info("文件中不存在有效的Token,将尝试获取新的Token")
# 验证获取新的Token功能
new_token = fetcher.get_token(max_retries=3)
if new_token:
logging.info("获取新的Token功能验证成功")
# 验证获取到的Token是否有效
is_valid = fetcher.validate_token(new_token)
if is_valid:
logging.info("获取到的Token有效且在有效期内")
else:
logging.warning("获取到的Token无效或已过期")
else:
logging.error("获取新的Token功能验证失败")
# 验证保存Token到文件功能
if new_token:
fetcher.save_token_to_file(new_token)
# 验证保存后文件中的Token
saved_token = fetcher.load_token_from_file()
if saved_token == new_token:
logging.info("保存Token到文件功能验证成功")
else:
logging.error("保存Token到文件功能验证失败")
else:
logging.error("由于未获取到新的Token,无法验证保存Token到文件功能")
# 验证获取有效Token功能
valid_token = fetcher.get_valid_token()
if valid_token:
logging.info("获取有效Token功能验证成功")
# 验证获取到的有效Token是否有效
is_valid = fetcher.validate_token(valid_token)
if is_valid:
logging.info("获取到的有效Token有效且在有效期内")
else:
logging.warning("获取到的有效Token无效或已过期")
else:
logging.error("获取有效Token功能验证失败")
#!/usr/bin/python
# -*- coding: utf-8 -*-
"""
@File : converter.py
@Create Time: 2025/4/25 10:14
@Description: 主函数入口
"""
import logging
from flask import Flask, request, render_template, jsonify, send_from_directory, url_for
import os
import pandas as pd
import xmindparser
from werkzeug.utils import secure_filename
import time
import requests
import json
from typing import List, Dict
from JiraTokenFetcher import TokenFetcher
app = Flask(__name__)
UPLOAD_FOLDER = 'uploads'
ALLOWED_EXTENSIONS = {'xmind'}
app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER
# JIRA API 配置
app.config['JIRA_API_URL'] = 'https://us.xray.cloud.getxray.app/api/internal/import/tests'
app.config['JIRA_HEADERS'] = {
'Accept': 'application/json, text/plain, */*',
'Accept-Encoding': 'gzip, deflate, br, zstd',
'Accept-Language': 'zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7',
'Content-Type': 'application/json;charset=UTF-8',
'Origin': 'https://us.xray.cloud.getxray.app',
'Host': 'us.xray.cloud.getxray.app'
}
# 配置日志
logging.basicConfig(level=logging.DEBUG)
app.logger.addHandler(logging.StreamHandler())
app.logger.setLevel(logging.DEBUG)
if not os.path.exists(UPLOAD_FOLDER):
os.makedirs(UPLOAD_FOLDER)
# 上传记录日志文件
UPLOAD_HISTORY_LOG = './log/upload_history.log'
os.makedirs(os.path.dirname(UPLOAD_HISTORY_LOG), exist_ok=True)
# 初始化单例TokenFetcher
class Singleton(type):
_instances = {}
def __call__(cls, *args, **kwargs):
if cls not in cls._instances:
cls._instances[cls] = super(Singleton, cls).__call__(*args, **kwargs)
return cls._instances[cls]
class TokenFetcherSingleton(TokenFetcher, metaclass=Singleton):
pass
token_fetcher = TokenFetcher(chrome_driver_path="C:/Program Files/Google/Chrome/Application/chromedriver.exe")
def allowed_file(filename):
return filename.endswith('.xmind')
@app.route('/')
def index():
return render_template('index.html')
@app.route('/help')
def help():
return render_template('help.html')
@app.route('/upload-history')
def upload_history():
history = []
if os.path.exists(UPLOAD_HISTORY_LOG):
with open(UPLOAD_HISTORY_LOG, 'r', encoding='utf-8') as f:
for line in f:
parts = line.strip().split(',')
if len(parts) == 3:
history.append({
'filename': parts[0],
'timestamp': parts[1],
'status': parts[2]
})
return render_template('upload_history.html', history=history)
@app.route('/upload', methods=['POST'])
def upload_file():
app.logger.debug("Received POST request at /upload")
if 'file' not in request.files:
app.logger.error("No file part in the request")
return jsonify({'status': 'error', 'message': 'No file part'})
file = request.files['file']
assignee_name = request.form.get('assignee', '').strip()
if not assignee_name:
assignee_name = "bin.lin@kone.com"
if file.filename == '':
app.logger.error("No selected file")
return jsonify({'status': 'error', 'message': 'No selected file'})
if file and allowed_file(file.filename):
filename = file.filename
filepath = os.path.join(app.config['UPLOAD_FOLDER'], filename)
file.save(filepath)
app.logger.info(f"File saved to {filepath}")
try:
csv_path = parse_xmind_to_csv(filepath, default_assignee=assignee_name)
app.logger.info(f"CSV file saved to {csv_path}")
download_url = url_for('download_file', filename=os.path.basename(csv_path), _external=True)
with open(UPLOAD_HISTORY_LOG, 'a', encoding='utf-8') as f:
timestamp = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime())
f.write(f"{filename},{timestamp},Success\n")
return jsonify({
'status': 'success',
'message': 'File uploaded and converted successfully.',
'data': {'csv_path': download_url}
})
except Exception as e:
app.logger.error(f"Error during file processing: {str(e)}")
with open(UPLOAD_HISTORY_LOG, 'a', encoding='utf-8') as f:
timestamp = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime())
f.write(f"{filename},{timestamp},Failed\n")
return jsonify({
'status': 'error',
'message': str(e)
})
else:
app.logger.error("The XMind filename is missing the '.xmind' extension!")
return jsonify({'status': 'error', 'message': 'The XMind filename is missing the \'.xmind\' extension!'})
def parse_xmind_to_csv(xmind_path, default_assignee="bin.lin@kone.com"):
app.logger.info(f"Processing XMind file: {xmind_path} with default assignee: {default_assignee}")
xmind_data = xmindparser.xmind_to_dict(xmind_path)
if not xmind_data:
raise ValueError("XMind file is empty or invalid.")
root_topic = xmind_data[0]['topic']
issue_id = 1
VALID_PRIORITIES = {"Highest", "High", "Medium", "Low", "Lowest"}
def extract_leaf_topics(topic, parent_title=""):
nonlocal issue_id
leaf_topics_list = []
title = topic['title']
full_title = f"{parent_title} -> {title}" if parent_title else title
sub_topics = topic.get('topics', [])
if not sub_topics:
if " -> " not in full_title:
raise ValueError(f"叶子节点标题必须包含 ' -> '!当前: {full_title}")
parts = [part.strip() for part in full_title.split(" -> ") if part.strip()]
n = len(parts)
if n < 4:
raise ValueError(
f"路径至少需要4段:... -> Description -> Action -> Expected -> Priority\n"
f"当前路径: {full_title}(共{n}段)"
)
priority_name = parts[-1]
expected_result = parts[-2]
action = parts[-3]
description = parts[-4]
summary_parts = parts[:-4]
if priority_name not in VALID_PRIORITIES:
raise ValueError(
f"Priority 无效: '{priority_name}'。合法值: {', '.join(sorted(VALID_PRIORITIES))}"
)
summary = " -> ".join(summary_parts) if summary_parts else "Test Case"
test_data = {
"Issue ID": issue_id,
"Test Type": "Manual",
"Priority Name": priority_name,
"Summary": summary,
"Description": description,
"Action": action,
"Expected Result": expected_result,
"Assignee Name": default_assignee
}
leaf_topics_list.append(test_data)
issue_id += 1
else:
for sub_topic in sub_topics:
leaf_topics_list.extend(extract_leaf_topics(sub_topic, full_title))
return leaf_topics_list
all_leaf_topics = extract_leaf_topics(root_topic)
if not all_leaf_topics:
raise ValueError("未找到任何符合格式的叶子节点!")
df = pd.DataFrame(all_leaf_topics)
original_filename = os.path.splitext(os.path.basename(xmind_path))[0]
csv_filename = f"{original_filename}.csv"
csv_path = os.path.join(app.config['UPLOAD_FOLDER'], csv_filename)
app.logger.info(f"Saving CSV file to {csv_path}")
try:
df.to_csv(csv_path, index=False, encoding='utf-8-sig')
app.logger.info(f"CSV file saved successfully: {csv_path}")
except Exception as e:
app.logger.error(f"Failed to save CSV file: {str(e)}")
raise
return csv_path
@app.route('/download/<path:filename>', methods=['GET'])
def download_file(filename):
return send_from_directory(app.config['UPLOAD_FOLDER'], filename, as_attachment=True)
@app.route('/clear-history', methods=['POST'])
def clear_history():
try:
if os.path.exists(UPLOAD_HISTORY_LOG):
open(UPLOAD_HISTORY_LOG, 'w', encoding='utf-8').close()
return jsonify({'status': 'success', 'message': 'History cleared successfully.'})
except Exception as e:
return jsonify({'status': 'error', 'message': str(e)})
def call_jira_api(json_data: List[Dict], project_key: str, x_acpt_value: str) -> Dict:
"""
带重试机制的Jira API调用
"""
url = f"{app.config['JIRA_API_URL']}?project={project_key}"
headers = app.config['JIRA_HEADERS'].copy()
headers['X-Acpt'] = x_acpt_value
for attempt in range(3):
try:
response = requests.post(
url,
headers=headers,
json=json_data,
timeout=30
)
response.raise_for_status()
app.logger.info(f"JIRA API Response StatusCode: {response.status_code}")
app.logger.info(f"JIRA API Response TextContent: {response.text}")
return response.json()
except requests.exceptions.RequestException as e:
app.logger.warning(f"第{attempt + 1}次请求失败: {str(e)}")
if attempt < 2:
time.sleep(2 ** attempt)
else:
app.logger.error("所有重试均失败")
return {'error': '请求失败'}
def check_import_status(job_id: str, x_acpt_value: str, max_wait_seconds=30) -> dict:
"""
轮询 Xray 导入任务状态,直到完成或超时。
JOB 终态为 'successful' 或 'unsuccessful',中间态如 'working' 需继续等待。
"""
status_url = "https://us.xray.cloud.getxray.app/api/internal/import/tests/status"
headers = app.config['JIRA_HEADERS'].copy()
headers['X-Acpt'] = x_acpt_value
headers.pop('Content-Type', None) # GET 请求不需要 Content-Type
start_time = time.time()
while time.time() - start_time < max_wait_seconds:
try:
response = requests.get(
f"{status_url}?jobId={job_id}",
headers=headers,
timeout=10
)
if response.status_code == 200:
result = response.json()
status = result.get('status')
if status == 'successful' or status == 'unsuccessful':
return result
else:
progress = result.get('progressValue', 'N/A')
app.logger.debug(f"Import in progress (status: {status}, progress: {progress}%), waiting...")
else:
app.logger.warning(f"Status check returned HTTP {response.status_code}: {response.text}")
except Exception as e:
app.logger.error(f"Error during import status polling: {e}")
time.sleep(2)
return {
'status': 'timeout',
'message': f'Import status check timed out after {max_wait_seconds} seconds.'
}
@app.route('/upload_to_jira', methods=['POST'])
def upload_to_jira():
app.logger.debug("Received POST request at /upload_to_jira")
try:
data = request.get_json()
csv_path = data.get('csv_path', '')
project_key = data.get('project_key', 'QR247')
if not csv_path:
return jsonify({'status': 'error', 'message': 'CSV path is required.'}), 400
csv_data = pd.read_csv(csv_path)
json_data = convert_to_jira_format(csv_data)
# 获取有效的 token
x_acpt_value = token_fetcher.get_valid_token()
if not x_acpt_value:
return jsonify({'status': 'error', 'message': 'Failed to retrieve X-Acpt token.'}), 500
jira_response = call_jira_api(json_data, project_key, x_acpt_value)
if 'jobId' not in jira_response:
return jsonify({
'status': 'error',
'message': 'Failed to upload to Jira, no jobId returned.',
'data': jira_response
}), 500
job_id = jira_response['jobId']
app.logger.info(f"Job ID received: {job_id}, polling import status...")
# 轮询真实导入结果
final_status = check_import_status(job_id, x_acpt_value)
if final_status.get('status') == 'successful':
view_url = f"https://******/jira/software/c/projects/{project_key}/issues/?jql=project%20%3D%20%22{project_key}%22"
return jsonify({
'status': 'success',
'message': f'Upload to Jira successful! <a href="{view_url}" target="_blank" class="text-blue-600 font-bold underline hover:text-blue-800">Click here to view the result</a>',
'data': final_status
})
elif final_status.get('status') == 'unsuccessful':
errors = final_status.get('result', {}).get('errors', [])
error_msgs = []
for err in errors:
elem_num = err.get('elementNumber', -1)
field_errors = err.get('errors', {})
for field, msg in field_errors.items():
row_display = elem_num + 2 if elem_num >= 0 else 'Unknown'
error_msgs.append(f"Row {row_display}: {field} → {msg}")
full_error = "; ".join(error_msgs) if error_msgs else "Import failed with unknown reason."
return jsonify({
'status': 'error',
'message': f'Import failed: {full_error}',
'data': final_status
}), 500
else:
msg = final_status.get('message') or 'Import did not complete successfully.'
return jsonify({
'status': 'error',
'message': msg,
'data': final_status
}), 500
except Exception as e:
app.logger.error(f"Error during upload to Jira: {str(e)}")
return jsonify({'status': 'error', 'message': str(e)}), 500
def convert_to_jira_format(csv_data):
jira_format = []
VALID_PRIORITIES = {"Highest", "High", "Medium", "Low", "Lowest"}
DEFAULT_ASSIGNEE = "bin.lin@kone.com"
def safe_str(val, default=""):
if pd.isna(val) or val is None:
return default
return str(val).strip()
def is_valid_email(email):
if not email:
return False
parts = email.split("@")
return len(parts) == 2 and "." in parts[1]
for idx, row in csv_data.iterrows():
row_num = idx + 2
summary = safe_str(row['Summary'])
if not summary:
app.logger.warning(f"Row {row_num}: Summary 为空,跳过该测试用例")
continue
raw_assignee = safe_str(row['Assignee Name'])
if is_valid_email(raw_assignee):
assignee_name = raw_assignee
else:
assignee_name = DEFAULT_ASSIGNEE
app.logger.warning(
f"Row {row_num}: Assignee '{raw_assignee}' 无效或为空,已设为默认负责人: {DEFAULT_ASSIGNEE}"
)
priority_name = safe_str(row['Priority Name'])
if not priority_name or priority_name not in VALID_PRIORITIES:
priority_name = "Medium"
app.logger.warning(f"Row {row_num}: Priority 无效或为空,设为 'Medium'")
description = safe_str(row['Description'])
test_type = safe_str(row['Test Type']) or "Manual"
action = safe_str(row['Action'])
expected_result = safe_str(row['Expected Result'])
jira_item = {
"fields": {
"priority": {"name": priority_name},
"summary": summary,
"description": description,
"assignee": {"name": assignee_name}
},
"xray_testtype": test_type,
"steps": [
{"action": action, "result": expected_result}
]
}
jira_format.append(jira_item)
app.logger.debug(f"Row {row_num} 转换成功 | Assignee: {assignee_name}")
if not jira_format:
raise ValueError("CSV 中无有效测试用例可上传")
return jira_format
if __name__ == '__main__':
app.run(debug=True, host='0.0.0.0', port=6060)
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>XMind to Jira Converter</title>
<link href="https://unpkg.com/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
<style>
#progress-bar, #jira-progress-bar {
transition: width 0.3s ease;
}
</style>
</head>
<body class="bg-gray-50 text-gray-800 font-sans">
<div class="container mx-auto max-w-2xl mt-10 p-6 bg-white shadow-lg rounded-lg">
<div class="flex justify-between items-center mb-6">
<h1 class="text-3xl font-bold">XMind to Jira Converter</h1>
<div class="flex items-center space-x-4">
<a href="/help" class="text-indigo-600 hover:text-indigo-800 transition-colors duration-300">Help</a>
<a href="/upload-history" class="text-indigo-600 hover:text-indigo-800 transition-colors duration-300">Upload History</a>
</div>
</div>
<form id="uploadForm" enctype="multipart/form-data" class="space-y-6">
<div class="relative">
<label for="fileInput" class="block text-sm font-medium text-gray-700">Select an XMind file (Max size: 16MB):</label>
<input type="file" id="fileInput" name="file" accept=".xmind" required class="mt-1 py-3 px-4 border border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm">
</div>
<!-- 新增 Assignee 输入框 -->
<div class="relative">
<label for="assigneeInput" class="block text-sm font-medium text-gray-700">Default Assignee Email:</label>
<input type="email" id="assigneeInput" name="assignee" placeholder="e.g., your.name@kone.com" required
class="mt-1 py-3 px-4 border border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm">
</div>
<!-- 项目下拉框 -->
<div class="relative">
<label for="projectSelect" class="block text-sm font-medium text-gray-700">Select Project:</label>
<select id="projectSelect" class="mt-1 py-3 px-4 border border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm">
<option value="" disabled selected>Select a project</option>
</select>
</div>
<div class="flex justify-between items-center">
<button type="submit" class="w-1/2 inline-flex justify-center py-3 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">Convert to CSV</button>
<button type="button" id="uploadToJira" class="w-1/2 inline-flex justify-center py-3 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500">Upload to Jira</button>
</div>
</form>
<button id="clearAll" class="mt-4 py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500">
Clear All
</button>
<div id="progress" class="hidden mt-6">
<div class="text-sm font-medium text-blue-600">Processing...</div>
<div class="w-full bg-gray-200 rounded-full h-3 mt-2">
<div id="progress-bar" class="bg-blue-600 h-3 rounded-full" style="width: 0%"></div>
</div>
</div>
<div id="jiraProgress" class="hidden mt-6">
<div class="text-sm font-medium text-green-600">Uploading to Jira...</div>
<div class="w-full bg-gray-200 rounded-full h-3 mt-2">
<div id="jira-progress-bar" class="bg-green-600 h-3 rounded-full" style="width: 0%"></div>
</div>
</div>
<div id="tokenMessage" class="hidden mt-6">
<div class="text-sm font-medium text-blue-600">Fetching Jira access token...</div>
</div>
<div id="result" class="hidden mt-6">
<!-- Result will be displayed here -->
</div>
</div>
<script src="{{ url_for('static', filename='js/scripts.js') }}"></script>
</body>
</html>
document.addEventListener('DOMContentLoaded', function() {
// 硬编码的项目数据
const projects = [
{ id: '10188', key: 'NP24', name: '******' },
{ id: '10650', key: 'QR247', name: '******' },
{ id: '10694', key: 'C3ST', name: '******' },
{ id: '10751', key: 'CKFM', name: '******' },
{ id: '13839', key: 'NCKFM', name: '******' },
{ id: '12056', key: 'IE', name: '******' },
{ id: '14135', key: 'DVC', name: '******' },
{ id: '10198', key: 'EB247', name: '******' },
{ id: '10853', key: 'CMS', name: '******' },
{ id: '10756', key: 'CCRM', name: ******' },
{ id: '10866', key: 'CDML', name: '******' },
{ id: '10638', key: 'CEM', name: '******' },
{ id: '10693', key: 'CMSS', name: '******' },
{ id: '10833', key: 'CPF', name: '******' },
{ id: '12023', key: 'KCCD', name: '******' },
{ id: '11264', key: 'KM', name: '******' },
{ id: '10797', key: 'NFM', name: '******' },
{ id: '10865', key: 'NGDTU', name: '******' },
];
// 填充下拉框
const projectSelect = document.getElementById('projectSelect');
projects.forEach(project => {
const option = document.createElement('option');
option.value = project.key;
option.textContent = project.name;
projectSelect.appendChild(option);
});
// 重置页面状态的函数
function resetPageState() {
document.getElementById('uploadForm').reset();
document.getElementById('progress').classList.add('hidden');
document.getElementById('jiraProgress').classList.add('hidden');
document.getElementById('result').classList.add('hidden');
document.getElementById('progress-bar').style.width = '0%';
document.getElementById('jira-progress-bar').style.width = '0%';
document.getElementById('result').innerHTML = '';
localStorage.removeItem('lastConvertedExcel');
}
// 清除按钮点击事件
document.getElementById('clearAll').addEventListener('click', resetPageState);
document.getElementById('uploadForm').addEventListener('submit', function(event) {
event.preventDefault();
const form = new FormData(this);
// 验证 assignee 邮箱格式(可选)
const assignee = document.getElementById('assigneeInput').value;
if (!assignee || !assignee.includes('@') || !assignee.includes('.')) {
alert('Please enter a valid email for Assignee.');
return;
}
// Show progress bar
document.getElementById('progress').classList.remove('hidden');
document.getElementById('result').classList.add('hidden');
document.getElementById('progress-bar').style.width = '0%';
fetch('/upload', {
method: 'POST',
mode: 'cors',
credentials: 'include',
body: form
})
.then(response => {
if (!response.ok) {
return response.text().then(text => {
throw new Error(`HTTP error! status: ${response.status}, body: ${text}`);
});
}
return response.json();
})
.then(data => {
// Hide progress bar
document.getElementById('progress').classList.add('hidden');
document.getElementById('result').classList.remove('hidden');
if (data.status === 'success') {
// Update result area with success message
document.getElementById('result').innerHTML = `
<div class="bg-green-100 border-l-4 border-green-500 p-4">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-green-500" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M5 2.5A2.5 2.5 0 017.5 0h6A2.5 2.5 0 0116 2.5v4.5A2.5 2.5 0 0113.5 9h-6A2.5 2.5 0 015 6.5v-4zM9 16.5A1 1 0 117 15.5a1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 102 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
</svg>
</div>
<div class="ml-3">
<p class="text-sm font-medium text-green-800">${data.message}</p>
</div>
</div>
</div>
<pre class="mt-4 p-4 bg-gray-900 text-white rounded-md">${JSON.stringify(data.data, null, 4)}</pre>
<a href="${data.data.csv_path}" download class="mt-2 inline-block bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 transition-colors duration-300">Download CSV</a>
`;
document.getElementById('uploadToJira').style.display = 'inline-flex';
// 保存 CSV 文件的下载链接到 localStorage
localStorage.setItem('lastConvertedExcel', data.data.csv_path);
} else {
// Update result area with error message
document.getElementById('result').innerHTML = `
<div class="bg-red-100 border-l-4 border-red-500 p-4">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-red-500" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 102 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
</svg>
</div>
<div class="ml-3">
<p class="text-sm font-medium text-red-800">${data.message}</p>
</div>
</div>
</div>
`;
document.getElementById('uploadToJira').style.display = 'none';
}
})
.catch(error => {
// Error handling
document.getElementById('progress').classList.add('hidden');
document.getElementById('result').classList.remove('hidden');
document.getElementById('result').innerHTML = `
<div class="bg-red-100 border-l-4 border-red-500 p-4">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-red-500" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 102 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
</svg>
</div>
<div class="ml-3">
<p class="text-sm font-medium text-red-800">An error occurred: ${error.message}</p>
</div>
</div>
</div>
`;
document.getElementById('uploadForm').reset();
document.getElementById('uploadToJira').style.display = 'none';
});
});
document.getElementById('uploadToJira').addEventListener('click', function() {
const csvPath = localStorage.getItem('lastConvertedExcel');
const selectedProjectKey = document.getElementById('projectSelect').value;
if (!csvPath) {
alert('Please convert an XMind file to CSV first.');
return;
}
if (!selectedProjectKey) {
alert('Please select a project before uploading to Jira.');
return;
}
document.getElementById('jiraProgress').classList.remove('hidden');
document.getElementById('jira-progress-bar').style.width = '0%';
fetch('/upload_to_jira', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ csv_path: csvPath, project_key: selectedProjectKey })
})
.then(response => response.json())
.then(jiraData => {
document.getElementById('jira-progress-bar').style.width = '100%';
if (jiraData.status === 'success') {
document.getElementById('result').innerHTML += `
<div class="mt-6 bg-blue-100 border-l-4 border-blue-500 p-4">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-blue-500" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9V6z" clip-rule="evenodd" />
</svg>
</div>
<div class="ml-3">
<p class="text-sm font-medium text-blue-800">${jiraData.message}</p>
</div>
</div>
</div>
`;
} else {
document.getElementById('result').innerHTML += `
<div class="mt-6 bg-red-100 border-l-4 border-red-500 p-4">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-red-500" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 102 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
</svg>
</div>
<div class="ml-3">
<p class="text-sm font-medium text-red-800">Error uploading to Jira: ${jiraData.message}</p>
</div>
</div>
</div>
`;
}
})
.catch(error => {
document.getElementById('jira-progress-bar').style.width = '100%';
document.getElementById('result').innerHTML += `
<div class="mt-6 bg-red-100 border-l-4 border-red-500 p-4">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-red-500" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 102 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
</svg>
</div>
<div class="ml-3">
<p class="text-sm font-medium text-red-800">Error uploading to Jira: ${error.message}</p>
</div>
</div>
</div>
`;
});
});
});
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Upload Result</title>
<link href="https://unpkg.com/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
</head>
<body class="bg-gray-100 font-sans">
<div class="container mx-auto max-w-md mt-10 p-6 bg-white shadow-lg rounded-lg">
<h1 class="text-3xl font-bold text-center mb-6">Upload Result</h1>
{% if result.status == 'success' %}
<div class="alert alert-success text-center mb-4">
<svg class="w-6 h-6 inline text-green-600 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
{{ result.message }}
</div>
<pre class="bg-gray-900 text-white p-4 rounded-md mt-4">{{ result.data | tojson(indent=4) }}</pre>
{% elif result.status == 'error' %}
<div class="alert alert-danger text-center mb-4">
<svg class="w-6 h-6 inline text-red-600 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
{{ result.message }}
</div>
{% endif %}
<div class="text-center">
<a href="{{ url_for('index') }}" class="inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">Go Back</a>
</div>
</div>
</body>
</html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Upload History - XMind to CSV Converter</title>
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
</head>
<body class="bg-gray-100 font-sans">
<div class="container mx-auto max-w-2xl mt-10 p-6 bg-white shadow-lg rounded-lg">
<h1 class="text-3xl font-bold mb-6">Upload History</h1>
<div class="mb-6">
<button id="clearHistory" class="bg-red-600 text-white px-4 py-2 rounded hover:bg-red-700 transition-colors duration-300">
Clear All History
</button>
</div>
<div class="overflow-x-auto">
<table class="table-auto w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">File Name</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Upload Date</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
{% for item in history %}
<tr>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">{{ item.filename }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ item.timestamp }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium {% if item.status == 'Success' %}text-green-600{% else %}text-red-600{% endif %}">{{ item.status }}</td>
<td class="px-6 py-4 whitespace-nowrap">
{% if item.status == 'Success' %}
<a href="/download/{{ item.filename | replace('.xmind', '.csv') }}" class="text-indigo-600 hover:text-indigo-800 text-sm font-medium mr-2 transition-colors duration-300">Download CSV</a>
<a href="#" class="text-indigo-600 hover:text-indigo-800 text-sm font-medium transition-colors duration-300">Upload to Jira</a>
{% else %}
<a href="#" class="text-indigo-600 hover:text-indigo-800 text-sm font-medium transition-colors duration-300">View Error</a>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="mt-6 text-center">
<a href="/" class="text-indigo-600 hover:text-indigo-800 font-medium transition-colors duration-300">Back to Converter</a>
</div>
</div>
<script>
document.getElementById('clearHistory').addEventListener('click', function() {
if (confirm('Are you sure you want to clear all upload history? This action cannot be undone.')) {
fetch('/clear-history', {
method: 'POST'
})
.then(response => response.json())
.then(data => {
if (data.status === 'success') {
// Refresh the page to show cleared history
location.reload();
} else {
alert('Failed to clear history: ' + data.message);
}
})
.catch(error => {
alert('Error clearing history: ' + error.message);
});
}
});
</script>
</body>
</html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Help - XMind to JIRA Converter</title>
<link href="https://unpkg.com/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
</head>
<body class="bg-gray-100 font-sans">
<div class="container mx-auto max-w-2xl mt-10 p-6 bg-white shadow-lg rounded-lg">
<h1 class="text-3xl font-bold mb-6">Help</h1>
<div class="space-y-6">
<div class="bg-blue-50 p-6 rounded-lg">
<h2 class="text-xl font-semibold text-blue-800 mb-2">How to Use</h2>
<p class="text-gray-600">
This tool allows you to convert XMind files (.xmind) to CSV format. Follow these simple steps:
</p>
<ol class="list-decimal list-inside mt-4 space-y-2 text-gray-600">
<li>Click the "Select an XMind file" button to choose your XMind file from your device.</li>
<li>Click the "Convert to CSV" button to process the file.</li>
<li>Wait for the conversion process to complete (this may take a few seconds).</li>
<li>Once the conversion is complete, you can download the CSV file by clicking the "Download CSV" button.</li>
<li>Optionally, you can upload the CSV file to Jira by clicking the "Upload to Jira" button.</li>
</ol>
</div>
<div class="bg-green-50 p-6 rounded-lg">
<h2 class="text-xl font-semibold text-green-800 mb-2">File Requirements</h2>
<p class="text-gray-600">
Ensure your XMind file meets the following requirements for successful conversion:
</p>
<ul class="list-disc list-inside mt-4 space-y-2 text-gray-600">
<li>The XMind file should have a hierarchical structure that can be mapped to CSV rows and columns.</li>
<li>The file size should not exceed 16MB.</li>
<li>The file should not be corrupted and should have a valid XMind structure.</li>
<li>The file should contain at least one topic with a title.</li>
</ul>
</div>
<div class="bg-yellow-50 p-6 rounded-lg">
<h2 class="text-xl font-semibold text-yellow-800 mb-2">Common Issues and Solutions</h2>
<p class="text-gray-600">
If you encounter any issues, refer to the following solutions:
</p>
<ul class="list-disc list-inside mt-4 space-y-2 text-gray-600">
<li><strong>Conversion Failed:</strong> Ensure your XMind file has a valid structure and meets the file requirements. Check that the file is not corrupted.</li>
<li><strong>File Not Found:</strong> Make sure the file is correctly selected and not moved or deleted from its original location.</li>
<li><strong>Upload to Jira Failed:</strong> Verify that the CSV file was successfully generated before attempting to upload to Jira. Check your Jira connection settings.</li>
<li><strong>Empty CSV:</strong> Ensure your XMind file contains topics with titles and content.</li>
</ul>
</div>
<div class="bg-red-50 p-6 rounded-lg">
<h2 class="text-xl font-semibold text-red-800 mb-2">Contact Support</h2>
<p class="text-gray-600">
If you need further assistance or have questions about the tool, feel free to contact me:
</p>
<div class="mt-4">
<p class="text-gray-600">Email: XX@XX.com</p>
<p class="text-gray-600">Phone: +(86) 100-0000-0000</p>
</div>
</div>
</div>
<div class="mt-6 text-center">
<a href="/" class="text-indigo-600 hover:text-indigo-800 font-medium transition-colors duration-300">Back to Converter</a>
</div>
</div>
</body>
</html>




