#!/bin/sh set -eu LANG_CODE="" SILLYTAVERN_ROOT="" WORK_DIR="" ZIP_PATH="" FINAL_ZIP_PATH="" INCLUDE_BACKUPS=0 IS_TERMUX=0 READ_INPUT="" ACTIVE_CHILD_PID="" cleanup() { if [ -n "${ACTIVE_CHILD_PID:-}" ]; then kill "$ACTIVE_CHILD_PID" >/dev/null 2>&1 || true wait "$ACTIVE_CHILD_PID" 2>/dev/null || true ACTIVE_CHILD_PID="" fi if [ -n "${WORK_DIR:-}" ] && [ -d "$WORK_DIR" ]; then rm -rf "$WORK_DIR" || true fi } handle_signal() { exit_status=$1 trap - EXIT INT TERM printf '\n' >&2 cleanup exit "$exit_status" } trap cleanup EXIT trap 'handle_signal 130' INT trap 'handle_signal 143' TERM msg() { case "${LANG_CODE}:$1" in ":language_title"|"zh:language_title") printf '%s' "请选择脚本语言 / Please choose a language" ;; "en:language_title") printf '%s' "Choose a script language / 请选择脚本语言" ;; ":language_option_zh"|"zh:language_option_zh") printf '%s' "1) 中文" ;; "en:language_option_zh") printf '%s' "1) Chinese" ;; ":language_option_en"|"zh:language_option_en") printf '%s' "2) English" ;; "en:language_option_en") printf '%s' "2) English" ;; ":language_prompt"|"zh:language_prompt") printf '%s' "请输入 1 或 2,直接回车默认中文: " ;; "en:language_prompt") printf '%s' "Enter 1 or 2. Press Enter for English: " ;; "zh:banner_title") printf '%s' "TauriTavern SillyTavern Migration Export" ;; "en:banner_title") printf '%s' "TauriTavern SillyTavern Migration Export" ;; "zh:banner_subtitle") printf '%s' "这个脚本会生成一个可直接导入 TauriTavern 的 zip。" ;; "en:banner_subtitle") printf '%s' "This script creates a zip that can be imported directly by TauriTavern." ;; "zh:not_in_root") printf '%s' "当前目录不是 SillyTavern 根目录。" ;; "en:not_in_root") printf '%s' "The current directory is not a SillyTavern root." ;; "zh:root_prompt") printf '%s' "请输入 SillyTavern 根目录路径:" ;; "en:root_prompt") printf '%s' "Enter the SillyTavern root path:" ;; "zh:root_requirements") printf '%s' "路径内需要包含 data/default-user 和 public/scripts/extensions/third-party。" ;; "en:root_requirements") printf '%s' "The path must contain data/default-user and public/scripts/extensions/third-party." ;; "zh:root_invalid") printf '%s' "这个路径看起来不是有效的 SillyTavern 根目录,请重新输入。" ;; "en:root_invalid") printf '%s' "That path does not look like a valid SillyTavern root. Please try again." ;; "zh:detected_root") printf '%s' "已检测到 SillyTavern 根目录:" ;; "en:detected_root") printf '%s' "Detected SillyTavern root:" ;; "zh:ask_backups") printf '%s' "是否导出 data/default-user/backups?跳过导出备份会明显更快。 [y/N]: " ;; "en:ask_backups") printf '%s' "Export data/default-user/backups as well? Choosing No is usually much faster. [y/N]: " ;; "zh:summary_title") printf '%s' "执行计划" ;; "en:summary_title") printf '%s' "Execution Plan" ;; "zh:summary_root") printf '%s' "SillyTavern 根目录" ;; "en:summary_root") printf '%s' "SillyTavern root" ;; "zh:summary_backups") printf '%s' "导出 backups" ;; "en:summary_backups") printf '%s' "Export backups" ;; "zh:summary_global_ext") printf '%s' "全局扩展映射" ;; "en:summary_global_ext") printf '%s' "Global extension mapping" ;; "zh:yes") printf '%s' "是" ;; "en:yes") printf '%s' "Yes" ;; "zh:no") printf '%s' "否" ;; "en:no") printf '%s' "No" ;; "zh:step_prepare") printf '%s' "准备临时工作区" ;; "en:step_prepare") printf '%s' "Preparing temporary workspace" ;; "zh:step_copy_user") printf '%s' "复制 default-user 数据" ;; "en:step_copy_user") printf '%s' "Copying default-user data" ;; "zh:step_skip_backups") printf '%s' "已按选择跳过 backups。" ;; "en:step_skip_backups") printf '%s' "Skipping backups as requested." ;; "zh:step_copy_extensions") printf '%s' "复制全局 third-party 扩展" ;; "en:step_copy_extensions") printf '%s' "Copying global third-party extensions" ;; "zh:step_count_entries") printf '%s' "统计压缩条目数量" ;; "en:step_count_entries") printf '%s' "Counting archive entries" ;; "zh:step_zip") printf '%s' "开始压缩 zip" ;; "en:step_zip") printf '%s' "Creating zip archive" ;; "zh:zip_requires") printf '%s' "未找到 zip 命令。请先安装 zip 后再运行。" ;; "en:zip_requires") printf '%s' "The zip command is missing. Please install zip first." ;; "zh:zip_requires_termux") printf '%s' "在 Termux 中可先执行: pkg install zip" ;; "en:zip_requires_termux") printf '%s' "On Termux you can install it with: pkg install zip" ;; ":input_required"|"zh:input_required") printf '%s' "无法读取交互输入。请在可交互终端中运行这个脚本。" ;; "en:input_required") printf '%s' "Unable to read interactive input. Please run this script from an interactive terminal." ;; "zh:zip_progress") printf '%s' "压缩进度" ;; "en:zip_progress") printf '%s' "Zip progress" ;; "zh:step_move_termux") printf '%s' "尝试移动到 Android 下载目录" ;; "en:step_move_termux") printf '%s' "Trying to move the zip to the Android Downloads folder" ;; "zh:termux_auth") printf '%s' "正在调用 termux-setup-storage 申请存储授权..." ;; "en:termux_auth") printf '%s' "Requesting storage access with termux-setup-storage..." ;; "zh:termux_auth_failed") printf '%s' "存储授权失败,zip 将保留在原位置。" ;; "en:termux_auth_failed") printf '%s' "Storage authorization failed. The zip will stay where it is." ;; "zh:step_move_downloads") printf '%s' "尝试移动到 Downloads" ;; "en:step_move_downloads") printf '%s' "Trying to move the zip to Downloads" ;; "zh:move_success") printf '%s' "已成功移动 zip。" ;; "en:move_success") printf '%s' "The zip was moved successfully." ;; "zh:move_failed") printf '%s' "移动失败,zip 将保留在原位置。" ;; "en:move_failed") printf '%s' "Moving failed. The zip will stay at the original location." ;; "zh:final_title") printf '%s' "导出完成" ;; "en:final_title") printf '%s' "Export Completed" ;; "zh:final_path_label") printf '%s' "zip 文件位置" ;; "en:final_path_label") printf '%s' "Zip file location" ;; "zh:final_hint") printf '%s' "现在可以在 TauriTavern 的 data-migration 扩展中导入这个 zip。" ;; "en:final_hint") printf '%s' "You can now import this zip from the data-migration extension in TauriTavern." ;; ":warning"|"zh:warning") printf '%s' "警告" ;; "en:warning") printf '%s' "Warning" ;; ":info"|"zh:info") printf '%s' "信息" ;; "en:info") printf '%s' "Info" ;; ":error"|"zh:error") printf '%s' "错误" ;; "en:error") printf '%s' "Error" ;; *) printf '%s' "$1" ;; esac } print_banner() { printf '\n============================================================\n' printf '%s\n' "$(msg "banner_title")" printf '%s\n' "$(msg "banner_subtitle")" printf '============================================================\n\n' } print_step() { printf '\n[%s] %s\n' "$1" "$2" } print_info() { printf '%s: %s\n' "$(msg "info")" "$1" } print_warning() { printf '%s: %s\n' "$(msg "warning")" "$1" >&2 } print_error() { printf '%s: %s\n' "$(msg "error")" "$1" >&2 } read_user_input() { READ_INPUT="" if [ -t 0 ]; then IFS= read -r READ_INPUT return $? fi if [ -t 1 ] || [ -t 2 ]; then IFS= read -r READ_INPUT 2>/dev/null < /dev/tty return $? fi return 1 } read_user_input_or_exit() { if read_user_input; then return fi printf '\n' >&2 print_error "$(msg "input_required")" exit 1 } normalize_input_path() { printf '%s' "$1" | sed "s/^[[:space:]]*//;s/[[:space:]]*$//;s/^'//;s/'$//;s/^\"//;s/\"$//" } is_sillytavern_root() { candidate=$1 [ -d "$candidate/data/default-user" ] \ && [ -d "$candidate/public/scripts/extensions/third-party" ] \ && [ -f "$candidate/package.json" ] } select_language() { choice="" while :; do printf '%s\n' "$(msg "language_title")" printf '%s\n' "$(msg "language_option_zh")" printf '%s\n' "$(msg "language_option_en")" printf '%s' "$(msg "language_prompt")" read_user_input_or_exit choice=$READ_INPUT case "$choice" in ""|1) LANG_CODE="zh" return ;; 2) LANG_CODE="en" return ;; esac done } resolve_sillytavern_root() { current_dir=$(pwd -P) if is_sillytavern_root "$current_dir"; then SILLYTAVERN_ROOT=$current_dir print_info "$(msg "detected_root") $SILLYTAVERN_ROOT" return fi print_info "$(msg "not_in_root")" print_info "$(msg "root_requirements")" while :; do printf '%s\n' "$(msg "root_prompt")" printf '> ' read_user_input_or_exit input_path=$READ_INPUT input_path=$(normalize_input_path "$input_path") if [ -z "$input_path" ]; then print_warning "$(msg "root_invalid")" continue fi if ! is_sillytavern_root "$input_path"; then print_warning "$(msg "root_invalid")" continue fi SILLYTAVERN_ROOT=$(cd "$input_path" && pwd -P) print_info "$(msg "detected_root") $SILLYTAVERN_ROOT" return done } ask_backups() { printf '%s' "$(msg "ask_backups")" read_user_input_or_exit answer=$READ_INPUT case $(printf '%s' "$answer" | tr '[:upper:]' '[:lower:]') in y|yes|1|是) INCLUDE_BACKUPS=1 ;; *) INCLUDE_BACKUPS=0 ;; esac } detect_termux() { if command -v termux-setup-storage >/dev/null 2>&1; then IS_TERMUX=1 return fi case "${PREFIX:-}" in /data/data/com.termux/*) IS_TERMUX=1 ;; *) IS_TERMUX=0 ;; esac } require_zip() { if command -v zip >/dev/null 2>&1; then return fi print_error "$(msg "zip_requires")" if [ "$IS_TERMUX" -eq 1 ]; then print_error "$(msg "zip_requires_termux")" fi exit 1 } copy_directory_children() { source_dir=$1 destination_dir=$2 exclude_name=${3:-} mkdir -p "$destination_dir" if [ -n "$exclude_name" ]; then find "$source_dir" -mindepth 1 -maxdepth 1 ! -name "$exclude_name" -exec cp -R {} "$destination_dir" \; return fi find "$source_dir" -mindepth 1 -maxdepth 1 -exec cp -R {} "$destination_dir" \; } render_progress() { label=$1 current=$2 total=$3 width=32 if [ "$total" -le 0 ]; then total=1 fi if [ "$current" -gt "$total" ]; then current=$total fi percent=$((current * 100 / total)) filled=$((current * width / total)) empty=$((width - filled)) filled_bar=$(printf '%*s' "$filled" '' | tr ' ' '#') empty_bar=$(printf '%*s' "$empty" '' | tr ' ' '-') printf '\r%s [%s%s] %3s%% (%s/%s)' "$label" "$filled_bar" "$empty_bar" "$percent" "$current" "$total" } create_staging_zip() { source_data_root="$SILLYTAVERN_ROOT/data" source_global_extensions="$SILLYTAVERN_ROOT/public/scripts/extensions/third-party" staging_data_root="$WORK_DIR/data" staging_default_user="$staging_data_root/default-user" staging_global_extensions="$staging_data_root/extensions/third-party" print_step "1/5" "$(msg "step_prepare")" mkdir -p "$staging_default_user" mkdir -p "$staging_global_extensions" print_step "2/5" "$(msg "step_copy_user")" if [ "$INCLUDE_BACKUPS" -eq 1 ]; then copy_directory_children "$source_data_root/default-user" "$staging_default_user" else copy_directory_children "$source_data_root/default-user" "$staging_default_user" "backups" print_info "$(msg "step_skip_backups")" fi if [ "$INCLUDE_BACKUPS" -eq 1 ] && [ ! -d "$staging_default_user/backups" ]; then mkdir -p "$staging_default_user/backups" fi print_step "3/5" "$(msg "step_copy_extensions")" copy_directory_children "$source_global_extensions" "$staging_global_extensions" print_step "4/5" "$(msg "step_count_entries")" entry_count=$(find "$staging_data_root" | wc -l | awk '{print $1}') if [ -z "$entry_count" ] || [ "$entry_count" -le 0 ]; then entry_count=1 fi ZIP_PATH="$SILLYTAVERN_ROOT/tauritavern-data-$(date '+%Y%m%d-%H%M%S').zip" FINAL_ZIP_PATH="$ZIP_PATH" progress_fifo="$WORK_DIR/zip-progress.fifo" mkfifo "$progress_fifo" print_step "5/5" "$(msg "step_zip")" ( cd "$WORK_DIR" zip -r "$ZIP_PATH" data >"$progress_fifo" 2>&1 ) & zip_pid=$! ACTIVE_CHILD_PID=$zip_pid processed=0 while IFS= read -r line; do case "$line" in *"adding:"*) processed=$((processed + 1)) render_progress "$(msg "zip_progress")" "$processed" "$entry_count" ;; *"warning:"*|*"error:"*) printf '\n%s\n' "$line" >&2 ;; esac done <"$progress_fifo" zip_status=0 wait "$zip_pid" || zip_status=$? ACTIVE_CHILD_PID="" rm -f "$progress_fifo" if [ "$processed" -lt "$entry_count" ]; then render_progress "$(msg "zip_progress")" "$entry_count" "$entry_count" fi printf '\n' if [ "$zip_status" -ne 0 ]; then exit "$zip_status" fi } try_move_to_downloads() { if [ "$IS_TERMUX" -eq 1 ]; then download_dir="$HOME/storage/shared/Download" print_step "Post" "$(msg "step_move_termux")" print_info "$(msg "termux_auth")" if ! termux-setup-storage; then print_warning "$(msg "termux_auth_failed")" return fi else download_dir="$HOME/Downloads" print_step "Post" "$(msg "step_move_downloads")" fi if [ ! -d "$download_dir" ]; then print_warning "$(msg "move_failed")" return fi if mv "$ZIP_PATH" "$download_dir/"; then FINAL_ZIP_PATH="$download_dir/$(basename "$ZIP_PATH")" print_info "$(msg "move_success")" return fi print_warning "$(msg "move_failed")" } print_summary() { backups_label=$(msg "no") if [ "$INCLUDE_BACKUPS" -eq 1 ]; then backups_label=$(msg "yes") fi printf '\n------------------------------\n' printf '%s\n' "$(msg "summary_title")" printf '%s %s: %s\n' "-" "$(msg "summary_root")" "$SILLYTAVERN_ROOT" printf '%s %s: %s\n' "-" "$(msg "summary_backups")" "$backups_label" printf '%s %s: public/scripts/extensions/third-party -> data/extensions/third-party\n' "-" "$(msg "summary_global_ext")" printf '%s\n\n' "------------------------------" } print_final_location() { printf '\n============================================================\n' printf '%s\n' "$(msg "final_title")" printf '%s: %s\n' "$(msg "final_path_label")" "$FINAL_ZIP_PATH" printf '%s\n' "$(msg "final_hint")" printf '============================================================\n' } main() { select_language print_banner detect_termux require_zip resolve_sillytavern_root ask_backups print_summary WORK_DIR=$(mktemp -d "${TMPDIR:-/tmp}/tauritavern-migration.XXXXXX") create_staging_zip if [ "$IS_TERMUX" -eq 1 ]; then try_move_to_downloads fi print_final_location } main "$@"