My nix-darwin and NixOS config
3
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: sharkey emoji script

+762
+762
scripts/sharkey_emoji.sh
··· 1 + #!/usr/bin/env bash 2 + # sharkey_emoji.sh 3 + # Bulk-upload custom emoji from fedi-emojis-main to a Sharkey instance. 4 + # 5 + # Usage: 6 + # ./sharkey_emoji.sh [--clean] [--all] [--yes] 7 + # 8 + # --clean Delete ALL local custom emoji from the instance before uploading. 9 + # Prompts for confirmation unless --yes is also passed. 10 + # --all Upload all packs without interactive selection. 11 + # --yes Skip confirmation prompts. 12 + # 13 + # On first run the script will: 14 + # 1. Generate a MiAuth session and print an authorisation URL — open it in 15 + # your browser, log in, approve, then press ENTER in the terminal. 16 + # 2. Exchange the session for a bearer token (cached for future runs). 17 + # 18 + # Cached credentials are stored in ~/.config/sharkey-emoji-uploader/ 19 + # 20 + # Optional overrides (env vars): 21 + # SHARKEY_TOKEN=<api_token> (skip auto-auth entirely) 22 + # SHARKEY_INSTANCE=https://ap.ewancroft.uk (default) 23 + # EMOJI_DIR=/path/to/fedi-emojis-main (default) 24 + # EMOJI_SIZE_LIMIT_KB=5120 (default 5 MiB) 25 + # DRY_RUN=1 (print what would happen, no requests) 26 + # 27 + # Oversized emoji are automatically resized using ffmpeg if available. 28 + # 29 + # Sharkey API flow: 30 + # Upload emoji file → POST /api/drive/files/create (multipart) → fileId 31 + # Register emoji → POST /api/admin/emoji/add (JSON) 32 + # List emoji → POST /api/admin/emoji/list (JSON, sinceId pagination) 33 + # Delete emoji → POST /api/admin/emoji/delete (JSON) 34 + 35 + set -euo pipefail 36 + 37 + INSTANCE="${SHARKEY_INSTANCE:-https://ap.ewancroft.uk}" 38 + EMOJI_DIR="${EMOJI_DIR:-/Users/ewan/Developer/Other/fedi-emojis-main}" 39 + DRY_RUN="${DRY_RUN:-0}" 40 + 41 + EMOJI_SIZE_LIMIT_KB="${EMOJI_SIZE_LIMIT_KB:-5120}" 42 + EMOJI_SIZE_LIMIT=$((EMOJI_SIZE_LIMIT_KB * 1024)) 43 + 44 + CACHE_DIR="${HOME}/.config/sharkey-emoji-uploader" 45 + TOKEN_FILE="${CACHE_DIR}/token" 46 + 47 + CLEAN=0 48 + YES=0 49 + ALL_PACKS=0 50 + 51 + # --------------------------------------------------------------------------- 52 + # Argument parsing 53 + # --------------------------------------------------------------------------- 54 + 55 + for arg in "$@"; do 56 + case "$arg" in 57 + --clean) CLEAN=1 ;; 58 + --yes) YES=1 ;; 59 + --all) ALL_PACKS=1 ;; 60 + *) 61 + echo "Unknown argument: $arg" >&2 62 + echo "Usage: $0 [--clean] [--all] [--yes]" >&2 63 + exit 1 64 + ;; 65 + esac 66 + done 67 + 68 + # --------------------------------------------------------------------------- 69 + # MiAuth helpers 70 + # --------------------------------------------------------------------------- 71 + 72 + gen_uuid() { 73 + if command -v uuidgen &>/dev/null; then 74 + uuidgen | tr '[:upper:]' '[:lower:]' 75 + else 76 + cat /proc/sys/kernel/random/uuid 2>/dev/null || 77 + printf '%08x-%04x-%04x-%04x-%012x\n' \ 78 + $RANDOM$RANDOM $RANDOM $RANDOM $RANDOM $RANDOM$RANDOM$RANDOM 79 + fi 80 + } 81 + 82 + fetch_token_miauth() { 83 + local session_id 84 + session_id=$(gen_uuid) 85 + 86 + local permissions="read:admin:emoji,write:admin:emoji,write:drive,read:drive" 87 + local auth_url="${INSTANCE}/miauth/${session_id}?name=sharkey-emoji-uploader&permission=${permissions}" 88 + 89 + echo >&2 90 + echo "Open this URL in your browser to authorise the app:" >&2 91 + echo >&2 92 + echo " ${auth_url}" >&2 93 + echo >&2 94 + 95 + if command -v open &>/dev/null; then 96 + open "$auth_url" 2>/dev/null || true 97 + fi 98 + 99 + read -rp "Press ENTER once you have approved access in your browser..." _confirm </dev/tty 100 + 101 + local response 102 + response=$(curl -sf -X POST "${INSTANCE}/api/miauth/${session_id}/check" \ 103 + -H "Content-Type: application/json" \ 104 + -d '{}') 105 + 106 + local token 107 + token=$(echo "$response" | grep -o '"token":"[^"]*"' | head -1 | cut -d'"' -f4) 108 + 109 + if [[ -z "$token" ]]; then 110 + echo "Error: failed to obtain token. Response:" >&2 111 + echo "$response" >&2 112 + exit 1 113 + fi 114 + 115 + mkdir -p "$CACHE_DIR" 116 + chmod 700 "$CACHE_DIR" 117 + printf '%s\n' "$token" >"$TOKEN_FILE" 118 + chmod 600 "$TOKEN_FILE" 119 + echo "Token obtained and cached at ${TOKEN_FILE}." >&2 120 + echo "$token" 121 + } 122 + 123 + ensure_token() { 124 + if [[ -n "${SHARKEY_TOKEN:-}" ]]; then 125 + return 126 + fi 127 + 128 + if [[ -f "$TOKEN_FILE" ]]; then 129 + SHARKEY_TOKEN="$(cat "$TOKEN_FILE")" 130 + echo "Using cached token from ${TOKEN_FILE}." >&2 131 + return 132 + fi 133 + 134 + SHARKEY_TOKEN="$(fetch_token_miauth)" 135 + } 136 + 137 + # --------------------------------------------------------------------------- 138 + # Low-level API helper 139 + # --------------------------------------------------------------------------- 140 + 141 + # POST JSON to a Sharkey API endpoint. 142 + # Outputs the HTTP status code; response body goes to <body_file>. 143 + api_post() { 144 + local endpoint="$1" 145 + local payload="$2" 146 + local headers_file="$3" 147 + local body_file="$4" 148 + 149 + curl -s --max-time 60 \ 150 + -D "$headers_file" \ 151 + -o "$body_file" \ 152 + -w "%{http_code}" \ 153 + -X POST "${INSTANCE}${endpoint}" \ 154 + -H "Authorization: Bearer ${SHARKEY_TOKEN}" \ 155 + -H "Content-Type: application/json" \ 156 + -d "$payload" 157 + } 158 + 159 + # --------------------------------------------------------------------------- 160 + # Rate-limit helpers 161 + # --------------------------------------------------------------------------- 162 + 163 + parse_reset_timestamp() { 164 + local raw="$1" 165 + if [[ "$raw" =~ ^[0-9]+$ ]]; then 166 + echo "$raw" 167 + return 168 + fi 169 + if command -v date &>/dev/null; then 170 + local ts 171 + if date --version &>/dev/null 2>&1; then 172 + ts=$(date -d "$raw" +%s 2>/dev/null || true) 173 + else 174 + local clean="${raw%.*}" 175 + clean="${clean%Z}" 176 + ts=$(date -j -f "%Y-%m-%dT%H:%M:%S" "$clean" +%s 2>/dev/null || true) 177 + fi 178 + [[ -n "$ts" && "$ts" =~ ^[0-9]+$ ]] && echo "$ts" && return 179 + fi 180 + echo "" 181 + } 182 + 183 + rate_limit_sleep_time() { 184 + local headers_file="$1" 185 + local wait=60 186 + 187 + local reset_raw retry_after 188 + reset_raw=$(grep -i '^x-ratelimit-reset:' "$headers_file" 2>/dev/null | 189 + head -1 | sed 's/^[^:]*: *//' | tr -d '[:space:]\r' || true) 190 + retry_after=$(grep -i '^retry-after:' "$headers_file" 2>/dev/null | 191 + head -1 | tr -d '[:space:]\r' | cut -d':' -f2 || true) 192 + 193 + if [[ -n "$reset_raw" ]]; then 194 + local reset_ts 195 + reset_ts=$(parse_reset_timestamp "$reset_raw") 196 + if [[ -n "$reset_ts" ]]; then 197 + local now 198 + now=$(date +%s) 199 + wait=$((reset_ts - now + 1)) 200 + ((wait < 1)) && wait=1 201 + echo "$wait" 202 + return 203 + fi 204 + fi 205 + 206 + if [[ -n "$retry_after" && "$retry_after" =~ ^[0-9]+$ ]]; then 207 + echo "$retry_after" 208 + return 209 + fi 210 + 211 + echo "$wait" 212 + } 213 + 214 + respect_rate_limit() { 215 + local headers_file="$1" 216 + local label="${2:-}" 217 + 218 + local remaining 219 + remaining=$(grep -i '^x-ratelimit-remaining:' "$headers_file" 2>/dev/null | 220 + head -1 | tr -d '[:space:]\r' | cut -d':' -f2 || true) 221 + 222 + [[ -n "$remaining" && "$remaining" =~ ^[0-9]+$ ]] || return 0 223 + ((remaining > 0)) && return 0 224 + 225 + local wait 226 + wait=$(rate_limit_sleep_time "$headers_file") 227 + echo " [RATE] ${label:+$label — }bucket exhausted, sleeping ${wait}s until reset..." >&2 228 + sleep "$wait" 229 + } 230 + 231 + # --------------------------------------------------------------------------- 232 + # Emoji list helper (POST-based, sinceId pagination) 233 + # --------------------------------------------------------------------------- 234 + 235 + # Fetches all local emoji, optionally filtered by category query. 236 + # Outputs lines of: <id> <name> 237 + list_local_emoji() { 238 + local filter_category="${1:-}" 239 + local since_id="" 240 + local limit=100 241 + 242 + while true; do 243 + local payload 244 + payload=$(printf '{"limit":%d%s%s}' \ 245 + "$limit" \ 246 + "${since_id:+,\"sinceId\":\"${since_id}\"}" \ 247 + "${filter_category:+,\"query\":\"${filter_category}\"}") 248 + 249 + local http_status 250 + http_status=$(api_post /api/admin/emoji/list "$payload" \ 251 + /tmp/sk_list_headers.txt /tmp/sk_emoji_list.json) 252 + 253 + if [[ "$http_status" == "429" ]]; then 254 + local wait 255 + wait=$(rate_limit_sleep_time /tmp/sk_list_headers.txt) 256 + echo " [RATE] list — 429 rate limited, sleeping ${wait}s..." >&2 257 + sleep "$wait" 258 + continue 259 + fi 260 + 261 + if [[ "$http_status" != "200" ]]; then 262 + echo "Error: failed to list emoji (HTTP ${http_status})." >&2 263 + cat /tmp/sk_emoji_list.json >&2 264 + exit 1 265 + fi 266 + 267 + respect_rate_limit /tmp/sk_list_headers.txt 268 + 269 + local body 270 + body=$(cat /tmp/sk_emoji_list.json) 271 + 272 + local -a ids names 273 + mapfile -t ids < <(echo "$body" | grep -o '"id":"[^"]*"' | cut -d'"' -f4) 274 + mapfile -t names < <(echo "$body" | grep -o '"name":"[^"]*"' | cut -d'"' -f4) 275 + 276 + [[ ${#ids[@]} -eq 0 ]] && break 277 + 278 + for i in "${!ids[@]}"; do 279 + printf '%s %s\n' "${ids[$i]}" "${names[$i]:-unknown}" 280 + done 281 + 282 + [[ ${#ids[@]} -lt $limit ]] && break 283 + since_id="${ids[-1]}" 284 + done 285 + } 286 + 287 + # --------------------------------------------------------------------------- 288 + # Clean mode 289 + # --------------------------------------------------------------------------- 290 + 291 + delete_all_emoji() { 292 + echo "Fetching list of all local custom emoji..." >&2 293 + 294 + local -a all_ids=() 295 + mapfile -t all_ids < <(list_local_emoji | awk '{print $1}') 296 + 297 + local count=${#all_ids[@]} 298 + 299 + if [[ $count -eq 0 ]]; then 300 + echo "No local custom emoji found — nothing to delete." >&2 301 + return 302 + fi 303 + 304 + echo "Found ${count} local custom emoji." >&2 305 + 306 + if [[ "$YES" -ne 1 ]]; then 307 + read -rp "Delete all ${count} emoji? This cannot be undone. [y/N] " confirm </dev/tty 308 + [[ "${confirm,,}" == "y" ]] || { 309 + echo "Aborted." >&2 310 + exit 0 311 + } 312 + fi 313 + 314 + if [[ "$DRY_RUN" == "1" ]]; then 315 + echo "[DRY] Would delete ${count} emoji." >&2 316 + return 317 + fi 318 + 319 + local deleted=0 del_failed=0 320 + for id in "${all_ids[@]}"; do 321 + local http_status 322 + while true; do 323 + http_status=$(api_post /api/admin/emoji/delete \ 324 + "{\"id\":\"${id}\"}" \ 325 + /tmp/sk_del_headers.txt /tmp/sk_del_response.json) 326 + [[ "$http_status" != "429" ]] && break 327 + local wait 328 + wait=$(rate_limit_sleep_time /tmp/sk_del_headers.txt) 329 + echo " [RATE] delete ${id} — 429 rate limited, sleeping ${wait}s..." >&2 330 + sleep "$wait" 331 + done 332 + 333 + if [[ "$http_status" == "200" || "$http_status" == "204" ]]; then 334 + ((deleted++)) || true 335 + echo " [DEL] ${id}" 336 + else 337 + local err 338 + err=$(cat /tmp/sk_del_response.json 2>/dev/null || echo '{}') 339 + echo " [FAIL] delete ${id} — HTTP ${http_status}: ${err}" >&2 340 + ((del_failed++)) || true 341 + fi 342 + 343 + respect_rate_limit /tmp/sk_del_headers.txt 344 + done 345 + 346 + echo >&2 347 + echo "Deleted: ${deleted} Failed: ${del_failed}" >&2 348 + echo >&2 349 + } 350 + 351 + # --------------------------------------------------------------------------- 352 + # Upload helpers 353 + # --------------------------------------------------------------------------- 354 + 355 + shrink_emoji() { 356 + local file="$1" 357 + local ext="$2" 358 + 359 + command -v ffmpeg &>/dev/null || return 1 360 + 361 + local tmp="/tmp/sk_emoji_resized.${ext}" 362 + 363 + for scale in 0.75 0.5 0.35; do 364 + local vf="scale=trunc(iw*${scale}/2)*2:trunc(ih*${scale}/2)*2:flags=lanczos" 365 + case "${ext,,}" in 366 + gif) 367 + ffmpeg -y -loglevel error -i "$file" -vf "$vf" "$tmp" 2>/dev/null || return 1 368 + ;; 369 + jpg | jpeg) 370 + ffmpeg -y -loglevel error -i "$file" -vf "$vf" -q:v 4 "$tmp" 2>/dev/null || return 1 371 + ;; 372 + *) 373 + ffmpeg -y -loglevel error -i "$file" -vf "$vf" "$tmp" 2>/dev/null || return 1 374 + ;; 375 + esac 376 + 377 + local newsize 378 + newsize=$(wc -c <"$tmp") 379 + if ((newsize <= EMOJI_SIZE_LIMIT)); then 380 + echo "$tmp" 381 + return 0 382 + fi 383 + done 384 + 385 + return 1 386 + } 387 + 388 + # Step 1: Upload file to Sharkey drive → returns fileId. 389 + upload_to_drive() { 390 + local file="$1" 391 + local filename="$2" 392 + local mime="$3" 393 + 394 + local max_retries=4 395 + for ((attempt = 0; attempt <= max_retries; attempt++)); do 396 + local http_status 397 + http_status=$(curl -s --max-time 60 \ 398 + -D /tmp/sk_drive_headers.txt \ 399 + -o /tmp/sk_drive_response.json \ 400 + -w "%{http_code}" \ 401 + -X POST "${INSTANCE}/api/drive/files/create" \ 402 + -H "Authorization: Bearer ${SHARKEY_TOKEN}" \ 403 + -F "file=@${file};filename=${filename};type=${mime}" \ 404 + -F "isSensitive=false") 405 + 406 + if [[ "$http_status" == "429" && $attempt -lt $max_retries ]]; then 407 + local wait 408 + wait=$(rate_limit_sleep_time /tmp/sk_drive_headers.txt) 409 + echo " [WAIT] drive upload — 429 rate limited, sleeping ${wait}s..." >&2 410 + sleep "$wait" 411 + continue 412 + fi 413 + 414 + respect_rate_limit /tmp/sk_drive_headers.txt 415 + 416 + if [[ "$http_status" != "200" ]]; then 417 + echo "HTTP_${http_status}" 418 + return 419 + fi 420 + 421 + grep -o '"id":"[^"]*"' /tmp/sk_drive_response.json | head -1 | cut -d'"' -f4 422 + return 423 + done 424 + 425 + echo "" 426 + } 427 + 428 + # Step 2: Register a drive file as a custom emoji. 429 + add_emoji_from_drive() { 430 + local file_id="$1" 431 + local shortcode="$2" 432 + local category="$3" 433 + 434 + local payload 435 + payload=$(printf '{"fileId":"%s","name":"%s","category":"%s","aliases":[]}' \ 436 + "$file_id" "$shortcode" "$category") 437 + 438 + local max_retries=4 439 + for ((attempt = 0; attempt <= max_retries; attempt++)); do 440 + local http_status 441 + http_status=$(api_post /api/admin/emoji/add "$payload" \ 442 + /tmp/sk_emoji_headers.txt /tmp/sk_emoji_response.json) 443 + 444 + if [[ "$http_status" == "429" && $attempt -lt $max_retries ]]; then 445 + local wait 446 + wait=$(rate_limit_sleep_time /tmp/sk_emoji_headers.txt) 447 + echo " [WAIT] :${shortcode}: — 429 rate limited, sleeping ${wait}s..." >&2 448 + sleep "$wait" 449 + continue 450 + fi 451 + 452 + respect_rate_limit /tmp/sk_emoji_headers.txt 453 + echo "$http_status" 454 + return 455 + done 456 + 457 + echo "0" 458 + } 459 + 460 + # --------------------------------------------------------------------------- 461 + # Upload 462 + # --------------------------------------------------------------------------- 463 + 464 + ok=0 465 + skipped=0 466 + failed=0 467 + total=0 468 + 469 + upload_emoji() { 470 + local file="$1" 471 + local category="$2" 472 + local filename shortcode ext mime 473 + 474 + filename="$(basename "$file")" 475 + ext="${filename##*.}" 476 + shortcode="${filename%.*}" 477 + 478 + case "${ext,,}" in 479 + png) mime="image/png" ;; 480 + gif) mime="image/gif" ;; 481 + webp) mime="image/webp" ;; 482 + jpg | jpeg) mime="image/jpeg" ;; 483 + *) 484 + echo " [SKIP] $filename — unsupported extension" 485 + ((skipped++)) || true 486 + return 487 + ;; 488 + esac 489 + 490 + ((total++)) || true 491 + 492 + if [[ "$DRY_RUN" == "1" ]]; then 493 + echo " [DRY] :${shortcode}: (${category}) ← ${filename}" 494 + ((ok++)) || true 495 + return 496 + fi 497 + 498 + local upload_file="$file" 499 + local filesize 500 + filesize=$(wc -c <"$file") 501 + 502 + if ((filesize > EMOJI_SIZE_LIMIT)); then 503 + local shrunk 504 + if shrunk=$(shrink_emoji "$file" "$ext") && [[ -n "$shrunk" ]]; then 505 + local orig_kb=$((filesize / 1024)) 506 + local new_kb=$(($(wc -c <"$shrunk") / 1024)) 507 + echo " [SHRK] :${shortcode}: — resized ${orig_kb}KB → ${new_kb}KB" 508 + upload_file="$shrunk" 509 + else 510 + echo " [SKIP] :${shortcode}: — $((filesize / 1024))KB > ${EMOJI_SIZE_LIMIT_KB}KB limit (ffmpeg resize failed or unavailable)" 511 + ((skipped++)) || true 512 + return 513 + fi 514 + fi 515 + 516 + # Step 1: upload to drive 517 + local file_id 518 + file_id=$(upload_to_drive "$upload_file" "$filename" "$mime") 519 + 520 + if [[ -z "$file_id" || "$file_id" == HTTP_* ]]; then 521 + echo " [FAIL] :${shortcode}: — drive upload failed (${file_id:-empty response})" >&2 522 + ((failed++)) || true 523 + return 524 + fi 525 + 526 + # Step 2: register as emoji 527 + local http_status 528 + http_status=$(add_emoji_from_drive "$file_id" "$shortcode" "$category") 529 + 530 + case "$http_status" in 531 + 200 | 204) 532 + echo " [OK] :${shortcode}: (${category})" 533 + ((ok++)) || true 534 + ;; 535 + 401) 536 + echo " [FAIL] :${shortcode}: — 401 Unauthorised (token may be expired)" >&2 537 + echo " Hint: delete ${TOKEN_FILE} and re-run to get a fresh token." >&2 538 + ((failed++)) || true 539 + ;; 540 + 409) 541 + echo " [SKIP] :${shortcode}: — 409 already exists" 542 + ((skipped++)) || true 543 + ;; 544 + 422) 545 + local error 546 + error=$(grep -o '"message":"[^"]*"' /tmp/sk_emoji_response.json 2>/dev/null | head -1 || echo 'unknown') 547 + echo " [SKIP] :${shortcode}: — 422 ${error}" 548 + ((skipped++)) || true 549 + ;; 550 + 429) 551 + echo " [FAIL] :${shortcode}: — 429 rate limited after max retries" 552 + ((failed++)) || true 553 + ;; 554 + *) 555 + local error 556 + error=$(grep -o '"message":"[^"]*"' /tmp/sk_emoji_response.json 2>/dev/null | head -1 || echo 'unknown') 557 + echo " [FAIL] :${shortcode}: — HTTP ${http_status} ${error}" 558 + ((failed++)) || true 559 + ;; 560 + esac 561 + } 562 + 563 + # --------------------------------------------------------------------------- 564 + # Pack selection 565 + # --------------------------------------------------------------------------- 566 + 567 + select_packs() { 568 + local -a all_packs=() 569 + for pack_dir in "$EMOJI_DIR"/*/; do 570 + [[ -d "$pack_dir" ]] || continue 571 + all_packs+=("$(basename "$pack_dir")") 572 + done 573 + 574 + if [[ ${#all_packs[@]} -eq 0 ]]; then 575 + echo "Error: no pack directories found in $EMOJI_DIR" >&2 576 + exit 1 577 + fi 578 + 579 + if [[ "$ALL_PACKS" -eq 1 ]]; then 580 + for p in "${all_packs[@]}"; do SELECTED_PACKS["$p"]=1; done 581 + return 582 + fi 583 + 584 + local chosen 585 + if command -v fzf &>/dev/null; then 586 + chosen=$(printf '%s\n' "${all_packs[@]}" | 587 + fzf --multi \ 588 + --prompt='Select packs (TAB to toggle, ENTER to confirm): ' \ 589 + --height=50% \ 590 + --layout=reverse \ 591 + --bind='ctrl-a:toggle-all' \ 592 + --header='CTRL-A: toggle all' 2>/dev/tty) || { 593 + echo "No packs selected — aborting." >&2 594 + exit 0 595 + } 596 + while IFS= read -r p; do 597 + [[ -n "$p" ]] && SELECTED_PACKS["$p"]=1 598 + done <<<"$chosen" 599 + else 600 + local -a selected_flags=() 601 + for _ in "${all_packs[@]}"; do selected_flags+=(1); done 602 + 603 + while true; do 604 + echo >&2 605 + echo "Select packs to upload (deselected packs will have their emoji deleted)." >&2 606 + echo "Enter a number to toggle, 'a' to toggle all, or 'done' to confirm." >&2 607 + echo >&2 608 + for i in "${!all_packs[@]}"; do 609 + local mark 610 + [[ "${selected_flags[$i]}" -eq 1 ]] && mark="[x]" || mark="[ ]" 611 + printf ' %2d %s %s\n' "$((i + 1))" "$mark" "${all_packs[$i]}" >&2 612 + done 613 + echo >&2 614 + read -rp '> ' choice </dev/tty 615 + case "$choice" in 616 + done | '') 617 + break 618 + ;; 619 + a) 620 + local any_on=0 621 + for f in "${selected_flags[@]}"; do ((f)) && any_on=1 && break; done 622 + for i in "${!selected_flags[@]}"; do 623 + ((any_on)) && selected_flags[i]=0 || selected_flags[i]=1 624 + done 625 + ;; 626 + *[0-9]*) 627 + local idx=$((choice - 1)) 628 + if ((idx >= 0 && idx < ${#all_packs[@]})); then 629 + ((selected_flags[idx])) && selected_flags[idx]=0 || selected_flags[idx]=1 630 + else 631 + echo " Invalid choice." >&2 632 + fi 633 + ;; 634 + *) 635 + echo " Invalid choice." >&2 636 + ;; 637 + esac 638 + done 639 + 640 + for i in "${!all_packs[@]}"; do 641 + [[ "${selected_flags[$i]}" -eq 1 ]] && SELECTED_PACKS["${all_packs[$i]}"]=1 642 + done 643 + fi 644 + 645 + if [[ ${#SELECTED_PACKS[@]} -eq 0 ]]; then 646 + echo "No packs selected — aborting." >&2 647 + exit 0 648 + fi 649 + 650 + for p in "${all_packs[@]}"; do 651 + [[ -v SELECTED_PACKS["$p"] ]] || DESELECTED_PACKS+=("$p") 652 + done 653 + } 654 + 655 + delete_category_emoji() { 656 + local category="$1" 657 + echo " Removing existing emoji in category '${category}'..." 658 + 659 + local -a ids=() 660 + mapfile -t ids < <(list_local_emoji "$category" | awk '{print $1}') 661 + 662 + if [[ ${#ids[@]} -eq 0 ]]; then 663 + echo " (none found)" 664 + return 665 + fi 666 + 667 + for id in "${ids[@]}"; do 668 + if [[ "$DRY_RUN" == "1" ]]; then 669 + echo " [DRY] would delete emoji ${id} (category: ${category})" 670 + continue 671 + fi 672 + 673 + local del_status 674 + while true; do 675 + del_status=$(api_post /api/admin/emoji/delete \ 676 + "{\"id\":\"${id}\"}" \ 677 + /tmp/sk_del_headers.txt /tmp/sk_del_response.json) 678 + [[ "$del_status" != "429" ]] && break 679 + local wait 680 + wait=$(rate_limit_sleep_time /tmp/sk_del_headers.txt) 681 + echo " [RATE] delete ${id} — 429 rate limited, sleeping ${wait}s..." >&2 682 + sleep "$wait" 683 + done 684 + 685 + if [[ "$del_status" == "200" || "$del_status" == "204" ]]; then 686 + echo " [DEL] emoji ${id}" 687 + else 688 + echo " [FAIL] delete emoji ${id} — HTTP ${del_status}" >&2 689 + fi 690 + 691 + respect_rate_limit /tmp/sk_del_headers.txt 692 + done 693 + } 694 + 695 + # --------------------------------------------------------------------------- 696 + # Main 697 + # --------------------------------------------------------------------------- 698 + 699 + ensure_token 700 + 701 + if [[ ! -d "$EMOJI_DIR" ]]; then 702 + echo "Error: EMOJI_DIR not found: $EMOJI_DIR" >&2 703 + exit 1 704 + fi 705 + 706 + declare -A SELECTED_PACKS=() 707 + declare -a DESELECTED_PACKS=() 708 + 709 + select_packs 710 + 711 + echo 712 + echo "Sharkey emoji uploader" 713 + echo " instance : $INSTANCE" 714 + echo " emoji dir : $EMOJI_DIR" 715 + echo " size limit : ${EMOJI_SIZE_LIMIT_KB}KB" 716 + echo " dry run : $DRY_RUN" 717 + echo " clean : $CLEAN" 718 + echo " ffmpeg : $(command -v ffmpeg 2>/dev/null || echo 'not found — oversized emoji will be skipped')" 719 + echo " selected : ${!SELECTED_PACKS[*]}" 720 + echo " deselected : ${DESELECTED_PACKS[*]:-none}" 721 + echo 722 + 723 + if [[ "$CLEAN" -eq 1 ]]; then 724 + delete_all_emoji 725 + elif [[ ${#DESELECTED_PACKS[@]} -gt 0 ]]; then 726 + if [[ "$YES" -ne 1 ]]; then 727 + echo "The following pack categories will have their emoji deleted from the instance:" 728 + printf ' %s\n' "${DESELECTED_PACKS[@]}" 729 + read -rp "Proceed? [y/N] " confirm </dev/tty 730 + [[ "${confirm,,}" == "y" ]] || { 731 + echo "Aborted." >&2 732 + exit 0 733 + } 734 + echo 735 + fi 736 + for category in "${DESELECTED_PACKS[@]}"; do 737 + echo "── Removing deselected pack: ${category}" 738 + delete_category_emoji "$category" 739 + echo 740 + done 741 + fi 742 + 743 + for pack_dir in "$EMOJI_DIR"/*/; do 744 + [[ -d "$pack_dir" ]] || continue 745 + category="$(basename "$pack_dir")" 746 + [[ -v SELECTED_PACKS["$category"] ]] || continue 747 + 748 + echo "── Pack: ${category}" 749 + 750 + for file in "$pack_dir"*; do 751 + [[ -f "$file" ]] || continue 752 + upload_emoji "$file" "$category" 753 + done 754 + 755 + echo 756 + done 757 + 758 + echo "Done." 759 + echo " uploaded : $ok" 760 + echo " skipped : $skipped" 761 + echo " failed : $failed" 762 + echo " total : $total"