diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6bcec82..86179cb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,6 +24,12 @@ jobs: env: GH_TOKEN: ${{ github.token }} run: | - gh release create "${GITHUB_REF_NAME}" nfd2nfc-quick-action.zip \ - --title "${GITHUB_REF_NAME}" --generate-notes \ - || gh release upload "${GITHUB_REF_NAME}" nfd2nfc-quick-action.zip --clobber + set -euo pipefail + # 릴리스 존재 여부로 분기. `create || upload`는 create의 진짜 실패(권한·네트워크)를 + # upload 실패로 가려 디버깅을 어렵게 하므로, 존재할 때만(재실행/재태그) 자산을 갱신한다. + if gh release view "${GITHUB_REF_NAME}" >/dev/null 2>&1; then + gh release upload "${GITHUB_REF_NAME}" nfd2nfc-quick-action.zip --clobber + else + gh release create "${GITHUB_REF_NAME}" nfd2nfc-quick-action.zip \ + --title "${GITHUB_REF_NAME}" --generate-notes + fi diff --git a/CHANGELOG.md b/CHANGELOG.md index 3271400..4b5444b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,35 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/), 버전은 [Semantic Versioning](https://semver.org/lang/ko/)을 따릅니다. +## [Unreleased] + +### Added +- `-q`/`--quiet` — 요약 출력을 생략하는 조용한 모드(경고·에러는 stderr 유지). cron·스크립트용. +- `-f`/`--force` — 이름 충돌 시 덮어쓰기(기본은 안전하게 건너뜀). `--skip`은 기본값의 명시적 별칭. +- Quick Action에 `--notify` 추가 — 우클릭 정리 후 완료 토스트(변경/0변경 모두 피드백) + Finder reveal 병행. +- Homebrew formula `caveats` — CLI만 설치되며 Finder 우클릭 메뉴는 Releases에서 별도 설치임을 안내. +- 단문자 옵션 묶음 지원(`-nv` == `-n -v`) — `Getopt::Long`의 `bundling`. +- 존재하지 않는 입력 경로에 대한 경고(오타 등 조용한 무시 방지). +- `NFD2NFC_NO_GUI=1` 환경변수 — `--notify`/`--reveal`의 osascript 호출을 건너뜀(테스트·CI용). +- 통합 테스트: 종료 코드, inode 자기오인 방지(1.0.0 회귀 가드), 옵션 묶음, 중복 입력 dedup, `--quiet`, `--force` 케이스. + +### Changed +- 변환 실패·충돌 스킵·존재하지 않는 입력이 있으면 **종료 코드 1**로 종료(이전엔 항상 0이라 자동화가 실패를 감지 못함). Quick Action은 영향 없음. +- `install.sh`: CLI 설치 위치를 **이미 PATH에 있는 쓰기 가능 표준 위치**(Apple Silicon `/opt/homebrew/bin`, Intel `/usr/local/bin`) 우선 선택 — Apple Silicon에서 설치 직후 바로 실행되도록. PATH 미포함 시 안내 강화. +- `install.sh`: Finder 새로고침을 `killall`(강제 종료) 대신 graceful quit으로 — 진행 중인 Finder 작업 보호. +- `release.yml`: 릴리스 생성 폴백을 '이미 존재' 케이스로 한정(`create`의 진짜 실패가 `upload`로 가려지지 않게). + +### Fixed +- 같은 경로를 중복 지정하면 변경 카운트가 부풀려지고 같은 파일을 두 번 rename 시도하던 문제(경로 중복 제거). +- `test.sh`가 `--reveal` 포함 Quick Action 명령을 실행해 **실제 Finder를 띄우던 부작용** 제거(헤드리스 CI AppleEvent 타임아웃 위험 포함). `NFD2NFC_NO_GUI`로 차단. +- `test.sh` `--no-recurse` 단언이 약해(트리 전체 `count>=1`) 옵션이 깨져도 통과하던 false pass — 폴더 자신 NFC/내부 미처리를 분리 단언. +- `test.sh`가 리포 루트의 빌드 산출물(`.workflow.zip`)을 매 실행 덮어쓰던 문제 — 함수 모드로 임시 디렉토리에 빌드. +- `build-workflow.sh`: heredoc 종료 sentinel이 스크립트 본문에 출현하면 임베드가 조용히 깨지던 취약점 — 사전 검사(`grep -qxF`)로 차단. +- `build-workflow.sh`: zsh에서 `. ./build-workflow.sh`로 source 시 직접실행 가드가 오발해 의도치 않게 zip 빌드+번들 삭제하던 문제 — `BASH_SOURCE`/`ZSH_EVAL_CONTEXT` 기반 판별. +- Quick Action 명령에 빈 선택 가드(`[ "$#" -gt 0 ] || exit 0`)와 종료코드 0 래핑 — Automator가 빈 입력/부분 실패를 오류로 표시하지 않게. +- `install.sh` BIN_DIR 선택의 도달 불가능한 `elif` 죽은 코드 정리. +- README 영문 사용법 한 줄에 누락됐던 `--reveal`/`-h` 추가. + ## [1.0.1] - 2026-06-01 ### Changed diff --git a/README.md b/README.md index b182b9e..799bb44 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,9 @@ nfd2nfc -v 보고서.pdf 자료.xlsx # 여러 파일 + 변경 내역 출 | `--no-recurse` | 지정한 항목만 처리 (하위 폴더 안 들어감) | | `--notify` | 완료 후 macOS 알림 | | `--reveal` | 완료 후 Finder에서 결과 보여주기 | +| `-q`, `--quiet` | 조용히 실행 (요약 출력 생략, 경고·에러만) | +| `-f`, `--force` | 이름 충돌 시 덮어쓰기 (기본은 건너뜀) | +| `--skip` | 이름 충돌 시 건너뛰기 (기본) | | `-v`, `--verbose` | 변경 내역을 한 줄씩 출력 | | `-V`, `--version` | 버전 출력 | | `-h`, `--help` | 도움말 | @@ -218,7 +221,7 @@ characters stay the same; only the underlying Unicode form is normalized. | CLI + Finder Quick Action | `git clone … && cd nfd2nfc && ./install.sh` | ``` -nfd2nfc [--dry-run] [--no-recurse] [--notify] [-v] [-V] +nfd2nfc [--dry-run] [--no-recurse] [--notify] [--reveal] [-q] [-f] [-v] [-V] [-h] ``` Safe by design: already-NFC files are left untouched, clashes with genuinely different diff --git a/build-workflow.sh b/build-workflow.sh index e65886f..1f7e401 100755 --- a/build-workflow.sh +++ b/build-workflow.sh @@ -19,6 +19,14 @@ WORKFLOW_NAME="NFC로 이름 정리" # 주어진 경로에 완전한 .workflow 번들을 만든다(스크립트 본문 임베드). build_workflow_bundle() { bundle="$1" + + # 안전장치: heredoc 종료 sentinel(NFD2NFC_EOF)이 스크립트 본문에 한 줄로 출현하면 + # 임베드된 heredoc이 거기서 조기 종료돼 Quick Action이 조용히 깨진다(plutil lint는 통과). + if grep -qxF 'NFD2NFC_EOF' "$SCRIPT_SRC"; then + echo "오류: $SCRIPT_SRC 에 'NFD2NFC_EOF' 줄이 있어 heredoc 임베드가 깨집니다. sentinel을 바꾸세요." >&2 + return 1 + fi + rm -rf "$bundle" mkdir -p "$bundle/Contents" @@ -32,9 +40,14 @@ build_workflow_bundle() { # Quick Action이 실행할 셸 명령: # perl이 스크립트를 stdin(heredoc)으로 읽고, 선택된 파일들을 인자("$@")로 받는다. # 프로그램이 셸 명령줄이 아니라 heredoc 본문에 있으므로 셸 따옴표/이스케이프가 불필요. - my $cmd = qq{/usr/bin/perl - --reveal -- "\$\@" <<'NFD2NFC_EOF'\n} + # - --notify: 완료 토스트(변경/0변경 모두 피드백), --reveal: Finder에 결과 표시. + # - 선택이 비면 정상 종료(빈 인자에 nfd2nfc가 exit 1 → Automator 실패 표시 방지). + # - heredoc 뒤 'exit 0': perl이 부분 실패로 비0을 내도 GUI 피드백을 쓰는 + # Quick Action은 에러 다이얼로그를 띄우지 않도록 셸을 0으로 마친다. + my $cmd = qq{[ "\$#" -gt 0 ] || exit 0\n} + . qq{/usr/bin/perl - --reveal --notify -- "\$\@" <<'NFD2NFC_EOF'\n} . $body - . qq{NFD2NFC_EOF\n}; + . qq{NFD2NFC_EOF\nexit 0\n}; # plist 안에 들어가므로 XML 특수문자 이스케이프 my $esc = $cmd; @@ -228,8 +241,20 @@ PLIST GENPLIST } -# 직접 실행되면: 저장소에 .workflow 빌드 후 zip 재생성 -if [ "${0##*/}" = "build-workflow.sh" ]; then +# 직접 실행될 때만 저장소에 .workflow 빌드 후 zip 재생성한다. +# source되면(install.sh가 함수 재사용 / 대화형 셸에서 함수만 사용) 이 블록을 건너뛴다. +# sourced 판별: bash·sh는 BASH_SOURCE≠$0, zsh는 ZSH_EVAL_CONTEXT가 ':file'로 끝남. +# (이전엔 $0 basename만 봐서 zsh `. ./build-workflow.sh` 시 오발 → 의도치 않은 빌드+삭제가 일어났다.) +bw_sourced=1 +# shellcheck disable=SC3028,SC2128 +if [ -n "${BASH_SOURCE:-}" ]; then + [ "${BASH_SOURCE}" = "$0" ] && bw_sourced=0 +elif [ -n "${ZSH_EVAL_CONTEXT:-}" ]; then + case "$ZSH_EVAL_CONTEXT" in *:file) bw_sourced=1 ;; *) bw_sourced=0 ;; esac +else + [ "${0##*/}" = "build-workflow.sh" ] && bw_sourced=0 +fi +if [ "$bw_sourced" = 0 ]; then BUNDLE="$HERE/$WORKFLOW_NAME.workflow" build_workflow_bundle "$BUNDLE" # plist 유효성 검사 diff --git a/install.sh b/install.sh index 209e5af..84ee8b2 100755 --- a/install.sh +++ b/install.sh @@ -30,32 +30,47 @@ mkdir -p "$SERVICES_DIR" build_workflow_bundle "$SERVICES_DIR/$WORKFLOW_NAME.workflow" echo " ✓ Finder 우클릭 메뉴 설치: $SERVICES_DIR/$WORKFLOW_NAME.workflow" -# Quick Action이 메뉴에 즉시 보이도록 서비스 캐시 갱신 + Finder 재시작(실패해도 무방). -# Finder를 재시작하지 않으면 새 빠른 동작이 우클릭 메뉴에 바로 안 뜬다. +# Quick Action이 메뉴에 즉시 보이도록 서비스 캐시 갱신 + Finder 새로고침(실패해도 무방). +# Finder를 새로고침하지 않으면 새 빠른 동작이 우클릭 메뉴에 바로 안 뜬다. /System/Library/CoreServices/pbs -update >/dev/null 2>&1 || true /System/Library/CoreServices/pbs -flush >/dev/null 2>&1 || true -killall Finder >/dev/null 2>&1 || true # 메뉴 갱신용 (Finder가 즉시 다시 뜸) +# killall(강제 종료)은 진행 중인 Finder 복사/이동/이름변경을 끊을 수 있어, AppleEvent로 +# graceful하게 재시작을 요청한다(진행 중 작업이 있으면 Finder가 거부하므로 더 안전). +osascript -e 'tell application "Finder" to quit' >/dev/null 2>&1 || true +sleep 1 +open -a Finder >/dev/null 2>&1 || true # ── 2) CLI 설치 ──────────────────────────────────────────────────────────── -# /usr/local/bin 이 쓰기 가능하면 거기에, 아니면 ~/.local/bin 에 설치(둘 다 sudo 불필요). -if [ -w /usr/local/bin ] 2>/dev/null; then - BIN_DIR=/usr/local/bin -elif [ -d /usr/local/bin ] && mkdir -p /usr/local/bin 2>/dev/null && [ -w /usr/local/bin ]; then - BIN_DIR=/usr/local/bin -else - BIN_DIR="$HOME/.local/bin" +# CLI를 sudo 없이 설치할 bin 디렉토리 선택. +# 1순위: 이미 PATH에 있으면서 쓰기 가능한 표준 위치 → 그래야 설치 직후 바로 'nfd2nfc'가 잡힌다. +# (Apple Silicon Homebrew=/opt/homebrew/bin, Intel Homebrew=/usr/local/bin) +# 2순위: 쓰기 가능한 표준 위치(아직 PATH에 없을 수 있음). +# 3순위: ~/.local/bin (항상 쓰기 가능하나 기본 PATH엔 없을 수 있어 아래에서 안내). +BIN_DIR="" +for d in /opt/homebrew/bin /usr/local/bin; do + [ -d "$d" ] && [ -w "$d" ] || continue + case ":$PATH:" in *":$d:"*) BIN_DIR="$d"; break ;; esac +done +if [ -z "$BIN_DIR" ]; then + for d in /opt/homebrew/bin /usr/local/bin; do + if [ -d "$d" ] && [ -w "$d" ]; then BIN_DIR="$d"; break; fi + done fi +[ -z "$BIN_DIR" ] && BIN_DIR="$HOME/.local/bin" mkdir -p "$BIN_DIR" install -m 0755 "$SCRIPT_SRC" "$BIN_DIR/nfd2nfc" echo " ✓ CLI 설치: $BIN_DIR/nfd2nfc" # PATH 안내 case ":$PATH:" in - *":$BIN_DIR:"*) ;; # 이미 PATH에 있음 + *":$BIN_DIR:"*) ;; # 이미 PATH에 있음 → 새 터미널에서 바로 실행 가능 *) echo - echo " ⚠ $BIN_DIR 가 PATH에 없습니다. 아래 줄을 ~/.zshrc 에 추가하세요:" - echo " export PATH=\"$BIN_DIR:\$PATH\"" + echo " ⚠ $BIN_DIR 가 PATH에 없어, 'nfd2nfc' 명령이 바로 안 잡힙니다." + echo " • 지금 이 터미널에서 바로 쓰려면:" + echo " export PATH=\"$BIN_DIR:\$PATH\"" + echo " • 새 터미널에도 적용하려면 위 줄을 ~/.zshrc(쓰는 셸의 rc)에 추가." + echo " • Finder 우클릭 '빠른 동작'만 쓸 거면 이 경고는 무시해도 됩니다." ;; esac diff --git a/nfd2nfc b/nfd2nfc index 72bb25c..cb1eb6f 100755 --- a/nfd2nfc +++ b/nfd2nfc @@ -15,9 +15,19 @@ # -n, --dry-run 실제로 바꾸지 않고 바뀔 내용만 미리보기 # --no-recurse 폴더 안쪽까지 들어가지 않고 지정한 항목만 처리 # --notify 완료 후 macOS 알림 표시 +# --reveal 완료 후 Finder에서 대상 보여주기 +# -q, --quiet 조용히 (요약 출력 생략, 경고·에러만) +# -f, --force 이름 충돌 시 덮어쓰기 (기본은 건너뜀) +# --skip 이름 충돌 시 건너뛰기 (기본) # -v, --verbose 변경 내역을 한 줄씩 출력 # -V, --version 버전 출력 # -h, --help 도움말 +# (단문자 옵션은 묶어서 쓸 수 있음: 예) -nv == -n -v) +# +# 환경변수: +# NFD2NFC_NO_GUI=1 --notify/--reveal의 osascript 호출을 건너뜀(테스트·CI용) +# 종료 코드: +# 0 정상, 1 인자 오류 또는 변환 실패/충돌 스킵/존재하지 않는 입력이 있을 때 use strict; use warnings; @@ -27,15 +37,20 @@ use Getopt::Long qw(GetOptions); our $VERSION = "1.0.1"; -my ($dry, $recurse, $notify, $verbose, $help, $version, $reveal) = (0, 1, 0, 0, 0, 0, 0); +my ($dry, $recurse, $notify, $verbose, $help, $version, $reveal, $quiet, $force, $skip) + = (0, 1, 0, 0, 0, 0, 0, 0, 0, 0); # 대소문자 구분: -v(verbose)와 -V(version)가 섞이지 않도록. # (Getopt::Long은 기본적으로 대소문자를 무시해 -v/-V를 같은 옵션으로 취급한다) -Getopt::Long::Configure('no_ignore_case'); +# bundling: 단문자 옵션을 묶어서 쓸 수 있게(-nv == -n -v). 긴 옵션은 --로 시작하므로 안전. +Getopt::Long::Configure('no_ignore_case', 'bundling'); GetOptions( 'dry-run|n' => \$dry, 'recurse!' => \$recurse, 'notify' => \$notify, 'reveal' => \$reveal, + 'quiet|q' => \$quiet, + 'force|f' => \$force, # 이름 충돌 시 덮어쓰기(데이터 손실 가능) + 'skip' => \$skip, # 이름 충돌 시 건너뛰기(기본) — 명시용 별칭 'verbose|v' => \$verbose, 'version|V' => \$version, 'help|h' => \$help, @@ -45,6 +60,9 @@ if ($version) { print "nfd2nfc $VERSION\n"; exit 0; } usage(0) if $help; usage(1) unless @ARGV; +# quiet는 verbose보다 우선(둘 다 주면 조용히). --force가 --skip(기본)을 이긴다. +$verbose = 0 if $quiet; + # 사용자가 선택한 최상위 항목(옵션 제거 후 남은 인자) — 완료 후 Finder에 보여줄 대상 my @inputs = @ARGV; @@ -60,6 +78,9 @@ nfd2nfc — macOS 한글 파일명 NFD→NFC 변환기 --no-recurse 지정한 항목만 처리 (하위 폴더 제외) --notify 완료 후 macOS 알림 --reveal 완료 후 Finder에서 대상 보여주기 + -q, --quiet 조용히 (요약 출력 생략, 경고·에러만 표시) + -f, --force 이름 충돌 시 덮어쓰기 (기본은 건너뜀, 데이터 손실 주의) + --skip 이름 충돌 시 건너뛰기 (기본) -v, --verbose 변경 내역 출력 -V, --version 버전 출력 -h, --help 이 도움말 @@ -69,10 +90,20 @@ TXT # 1) 대상 경로 수집 (바이트 문자열 그대로 다룸 = 파일시스템 안전) my @targets; +my %seen; # 같은 경로 중복 수집 방지(카운트 부풀림·이중 rename 방지) +my $missing = 0; # 존재하지 않는 최상위 입력 개수(종료 코드에 반영) sub collect { - my ($path) = @_; + my ($path, $toplevel) = @_; $path =~ s{/+$}{}; # 끝 슬래시 제거 return if $path eq ''; + # 사용자가 직접 지정한 최상위 경로가 없으면 알린다(오타 등 조용한 무시 방지). + # APFS는 정규화 비구분이라 NFD로 준 경로도 -e가 잡지만, 정말 없는 경로는 걸러진다. + if ($toplevel && !-e $path && !-l $path) { + warn "경고: 경로가 존재하지 않음: $path\n"; + $missing++; + return; + } + return if $seen{$path}++; # 이미 수집한 경로는 건너뜀 push @targets, $path; if ($recurse && -d $path && !-l $path) { if (opendir(my $dh, $path)) { @@ -87,7 +118,7 @@ sub collect { } } } -collect($_) for @ARGV; +collect($_, 1) for @ARGV; # 2) 깊은 경로부터 처리(자식 → 부모) 해야 폴더 이름을 바꿔도 경로가 안 깨짐 my @sorted = sort { ($b =~ tr{/}{}) <=> ($a =~ tr{/}{}) } @targets; @@ -113,16 +144,17 @@ for my $p (@sorted) { # NFD로 저장된 파일도 NFC 이름으로 -e/lstat 검사하면 "존재"로 잡힌다. # 즉 단순 -e 검사는 자기 자신을 충돌로 오인해 전부 건너뛴다. # 따라서 '진짜 다른 파일'(다른 inode)이 그 이름을 차지한 경우에만 건너뛴다. + # --force면 충돌 검사를 우회하고 rename을 강행(기존 파일을 덮어씀). my @cur = lstat($p); my @tgt = lstat($new); - if (@tgt && (!@cur || $tgt[0] != $cur[0] || $tgt[1] != $cur[1])) { + if (!$force && @tgt && (!@cur || $tgt[0] != $cur[0] || $tgt[1] != $cur[1])) { warn "건너뜀(같은 이름의 다른 항목 존재): $p\n"; $skipped++; next; } if ($dry) { - print "[미리보기] $p → $new\n"; + print "[미리보기] $p → $new\n" unless $quiet; $changed++; } elsif (rename($p, $new)) { print "변경: $p → $new\n" if $verbose; @@ -138,9 +170,12 @@ my $summary = $dry ? "미리보기: ${changed}개 변경 예정" : "완료: ${changed}개 변경" . ($skipped ? ", ${skipped}개 건너뜀" : ""); -print "$summary\n"; +print "$summary\n" unless $quiet; -if ($notify && !$dry) { +# NFD2NFC_NO_GUI=1이면 osascript(알림·Finder) 호출을 건너뛴다. +# 테스트·CI(헤드리스)에서 Finder/알림 부작용·AppleEvent 타임아웃을 피하기 위함. +my $no_gui = $ENV{NFD2NFC_NO_GUI}; +if ($notify && !$dry && !$no_gui) { my $msg = $summary; $msg =~ s/"/\\"/g; system('/usr/bin/osascript', '-e', @@ -151,7 +186,7 @@ if ($notify && !$dry) { # 선택한 최상위 항목들의 '최종(NFC) 경로'를 Finder에서 보여준다. # - 폴더 하나만 선택했으면 그 폴더를 열어 정리된 내용을 바로 보여줌 # - 그 외엔 해당 항목들을 선택 상태로 표시(reveal) -if ($reveal && !$dry) { +if ($reveal && !$dry && !$no_gui) { my @final; for my $arg (@inputs) { (my $a = $arg) =~ s{/+$}{}; @@ -169,6 +204,11 @@ if ($reveal && !$dry) { reveal_in_finder(@final) if @final; } +# 변환 실패·충돌 스킵·존재하지 않는 입력이 하나라도 있으면 비0으로 종료한다. +# Quick Action은 exit code를 무시(build-workflow.sh가 exit 0으로 감쌈)하므로 GUI엔 영향 없고, +# CLI/CI 호출 측은 $?로 부분 실패를 감지할 수 있다. +exit(($skipped || $missing) ? 1 : 0); + # 주어진 절대경로들을 Finder에서 보여준다(폴더 1개면 열기, 그 외엔 선택). sub reveal_in_finder { my @paths = @_; diff --git a/nfd2nfc.rb b/nfd2nfc.rb index a6fe6e5..da90905 100644 --- a/nfd2nfc.rb +++ b/nfd2nfc.rb @@ -9,6 +9,15 @@ def install bin.install "nfd2nfc" end + def caveats + <<~EOS + CLI(`nfd2nfc`)만 설치됩니다. + Finder 우클릭 메뉴(빠른 동작 "NFC로 이름 정리")가 필요하면 + Releases에서 nfd2nfc-quick-action.zip을 받아 설치하세요: + https://github.com/wonjun-lab/nfd2nfc/releases/latest + EOS + end + test do assert_match "nfd2nfc", shell_output("#{bin}/nfd2nfc --version") end diff --git a/test.sh b/test.sh index 06a796f..2195116 100755 --- a/test.sh +++ b/test.sh @@ -8,10 +8,15 @@ NFD2NFC="$HERE/nfd2nfc" PASS=0 FAIL=0 +# 테스트 중 osascript(Finder/알림) 부작용·AppleEvent 타임아웃을 차단한다. +# (Quick Action 명령은 항상 --reveal을 포함하므로 이 가드가 없으면 Finder가 튀어나오고 +# 헤드리스 CI에서는 AppleEvent가 120초 타임아웃 날 수 있다.) +export NFD2NFC_NO_GUI=1 + ok() { PASS=$((PASS + 1)); printf ' \033[32m✓\033[0m %s\n' "$1"; } ng() { FAIL=$((FAIL + 1)); printf ' \033[31m✗\033[0m %s\n' "$1"; } -# 디렉토리 아래 NFD로 남은 항목 수 출력 +# 디렉토리 아래 NFD로 남은 항목 수 출력(시작 디렉토리 자신은 제외) count_nfd() { /usr/bin/perl -CSDA -MFile::Find -e ' use Unicode::Normalize qw(NFC); use Encode qw(decode_utf8); @@ -26,6 +31,30 @@ count_nfd() { ' "$1" } +# 단일 경로의 basename이 NFC면 exit 0, 아니면 exit 1 (디렉토리 비재귀) +is_nfc_base() { + /usr/bin/perl -e ' + use Unicode::Normalize qw(NFC); use Encode qw(decode_utf8); + my $p = $ARGV[0]; $p =~ s{/+$}{}; + my $i = rindex($p, "/"); my $b = $i < 0 ? $p : substr($p, $i + 1); + my $u = eval { my $c = $b; decode_utf8($c, Encode::FB_CROAK) }; + exit((defined $u && NFC($u) eq $u) ? 0 : 1); + ' "$1" +} + +# t10 등에서 최상위 첫 (디렉토리|파일) 항목 경로를 출력 +first_entry() { # first_entry + /usr/bin/perl -e ' + opendir(my $d, $ARGV[0]) or exit 0; + for (sort readdir $d) { + next if /^\.\.?$/; + my $p = "$ARGV[0]/$_"; + if ($ARGV[1] eq "d") { next unless -d $p } else { next unless -f $p } + print $p; last; + } + ' "$1" "$2" +} + # 표준 NFD 픽스처: /보고서.hwp, /하위폴더/사진.jpg make_fixture() { base="$1" @@ -58,11 +87,24 @@ make_fixture "$TMP/t3" /usr/bin/perl "$NFD2NFC" --dry-run "$TMP/t3" >/dev/null 2>&1 if [ "$(count_nfd "$TMP/t3")" -eq 3 ]; then ok "dry-run은 실제 변경 안 함"; else ng "dry-run이 파일을 변경함"; fi -# [4] --no-recurse: 지정 폴더만, 하위는 유지 +# [4] --no-recurse: 지정한 폴더 자신은 NFC로, 그 내부(사진.jpg)는 미처리로 남는다. +# 약한 단언(트리 전체 count>=1)은 옵션이 깨져도 통과하므로, 두 측면을 분리 단언한다. make_fixture "$TMP/t4" -sub=$(/usr/bin/perl -e 'opendir(my$d,$ARGV[0]);for(readdir$d){next if/^\.\.?$/;next unless -d "$ARGV[0]/$_";print "$ARGV[0]/$_";last}' "$TMP/t4") -/usr/bin/perl "$NFD2NFC" --no-recurse "$sub" >/dev/null 2>&1 -if [ "$(count_nfd "$TMP/t4")" -ge 1 ]; then ok "--no-recurse는 하위 미처리"; else ng "--no-recurse가 하위까지 처리함"; fi +sub=$(first_entry "$TMP/t4" d) +if [ -z "$sub" ] || [ ! -d "$sub" ]; then + ng "[4] 픽스처 하위폴더 탐색 실패(빈 경로)" +else + /usr/bin/perl "$NFD2NFC" --no-recurse "$sub" >/dev/null 2>&1 + # 변환 후 하위폴더는 NFC명으로 바뀌므로 부모에서 다시 찾는다. + subnfc=$(first_entry "$TMP/t4" d) + inside_nfd=$(count_nfd "$subnfc") + if is_nfc_base "$subnfc"; then base_ok=1; else base_ok=0; fi + if [ "$base_ok" -eq 1 ] && [ "$inside_nfd" -eq 1 ]; then + ok "--no-recurse: 폴더 자신만 NFC, 내부(사진.jpg) 미처리" + else + ng "--no-recurse 이상: 폴더basename NFC=$base_ok, 내부 NFD수=$inside_nfd (기대 1,1)" + fi +fi # [5] 심볼릭 링크 미추적 mkdir -p "$TMP/t5" @@ -76,17 +118,60 @@ mkdir -p "$TMP/t5" link_count=$(/usr/bin/perl -e 'my$n=0;opendir(my$d,$ARGV[0]);for(readdir$d){next if/^\.\.?$/;$n++ if -l "$ARGV[0]/$_"}print$n' "$TMP/t5") if [ "$link_count" -eq 1 ] && [ "$(count_nfd "$TMP/t5")" -eq 0 ]; then ok "심볼릭 링크 미추적 + 이름만 정규화"; else ng "심볼릭 링크 처리 이상"; fi -# [6] 생성된 Quick Action 명령 실행 (Automator 임시파일 방식) -"$HERE/build-workflow.sh" >/dev/null 2>&1 -unzip -oq "$HERE/NFC로 이름 정리.workflow.zip" -d "$TMP/wf" -DOC=$(find "$TMP/wf" -name document.wflow) -/usr/bin/plutil -extract "actions.0.action.ActionParameters.COMMAND_STRING" raw -o "$TMP/qa_cmd.sh" "$DOC" -make_fixture "$TMP/t6" -/usr/bin/perl -e ' - my @a; opendir(my $d, $ARGV[1]); for (readdir $d) { next if /^\.\.?$/; push @a, "$ARGV[1]/$_" } closedir $d; - system("/bin/zsh", $ARGV[0], @a); -' "$TMP/qa_cmd.sh" "$TMP/t6" -if [ "$(count_nfd "$TMP/t6")" -eq 0 ]; then ok "Quick Action 명령 실행 변환 성공"; else ng "Quick Action 명령 변환 실패"; fi +# [6] 생성된 Quick Action 명령 실행. 리포 루트 산출물을 건드리지 않게 함수 모드로 $TMP에 빌드한다. +# shellcheck source=build-workflow.sh +( . "$HERE/build-workflow.sh"; build_workflow_bundle "$TMP/qa.workflow" ) >/dev/null 2>&1 +DOC="$TMP/qa.workflow/Contents/document.wflow" +if [ ! -f "$DOC" ]; then + ng "[6] Quick Action 빌드 실패(document.wflow 없음)" +else + /usr/bin/plutil -extract "actions.0.action.ActionParameters.COMMAND_STRING" raw -o "$TMP/qa_cmd.sh" "$DOC" + make_fixture "$TMP/t6" + /usr/bin/perl -e ' + my @a; opendir(my $d, $ARGV[1]); for (readdir $d) { next if /^\.\.?$/; push @a, "$ARGV[1]/$_" } closedir $d; + system("/bin/zsh", $ARGV[0], @a); + ' "$TMP/qa_cmd.sh" "$TMP/t6" + if [ "$(count_nfd "$TMP/t6")" -eq 0 ]; then ok "Quick Action 명령 실행 변환 성공"; else ng "Quick Action 명령 변환 실패"; fi +fi + +# [7] 종료 코드: 정상 변환은 0, 존재하지 않는 입력은 비0(자동화가 부분 실패를 감지 가능) +make_fixture "$TMP/t7" +/usr/bin/perl "$NFD2NFC" "$TMP/t7" >/dev/null 2>&1; rc_ok=$? +/usr/bin/perl "$NFD2NFC" "$TMP/없는경로_xyz" >/dev/null 2>&1; rc_missing=$? +if [ "$rc_ok" -eq 0 ] && [ "$rc_missing" -ne 0 ]; then ok "종료 코드: 정상 0 / 실패 비0"; else ng "종료 코드 이상: 정상=$rc_ok 실패=$rc_missing"; fi + +# [8] inode 비교가 자기 자신을 충돌로 오인하지 않음 (1.0.0 핵심 버그의 회귀 가드). +# '진짜 다른 inode가 NFC명을 점유'한 충돌은 APFS 정규화 비구분 특성상 단일 볼륨에서 +# 재현 불가하므로, 그 반대(자기 자신 오인으로 전부 건너뛰는 회귀)를 막는다. +make_fixture "$TMP/t8" +err=$(/usr/bin/perl "$NFD2NFC" "$TMP/t8" 2>&1 >/dev/null) +if [ "$(count_nfd "$TMP/t8")" -eq 0 ] && ! printf '%s' "$err" | grep -q "건너뜀"; then + ok "inode 자기오인 없음(전부 변환, 건너뜀 경고 0)" +else + ng "inode 자기오인 의심(건너뜀 경고: $err)" +fi + +# [9] 단문자 옵션 묶음(-nv == -n -v): dry-run+verbose로 동작하되 실제 변경은 없어야 함 +make_fixture "$TMP/t9" +out=$(/usr/bin/perl "$NFD2NFC" -nv "$TMP/t9" 2>&1) +if echo "$out" | grep -q "미리보기" && [ "$(count_nfd "$TMP/t9")" -eq 3 ]; then ok "-nv 묶음(dry-run+verbose)"; else ng "-nv 묶음 이상: $out"; fi + +# [10] 중복 입력은 한 번만 처리(카운트 부풀림·이중 rename 없음) +make_fixture "$TMP/t10" +f=$(first_entry "$TMP/t10" f) +out=$(/usr/bin/perl "$NFD2NFC" --no-recurse "$f" "$f" 2>&1) +if echo "$out" | grep -q "1개 변경"; then ok "중복 입력 dedup(1개 변경)"; else ng "중복 입력 dedup 실패: $out"; fi + +# [11] --quiet: 요약 출력을 억제(stdout 비움)하되 변환은 정상 수행 +make_fixture "$TMP/t11" +qout=$(/usr/bin/perl "$NFD2NFC" --quiet "$TMP/t11" 2>/dev/null) +if [ -z "$qout" ] && [ "$(count_nfd "$TMP/t11")" -eq 0 ]; then ok "--quiet: 무출력 + 변환 수행"; else ng "--quiet 이상(out=[$qout])"; fi + +# [12] --force: 충돌 검사를 우회해도 일반 변환을 정상 수행하고 건너뜀 경고가 없어야 함. +# (진짜 다른 inode가 NFC명을 점유한 충돌은 APFS 정규화 비구분 특성상 단일 볼륨에서 재현 불가) +make_fixture "$TMP/t12" +ferr=$(/usr/bin/perl "$NFD2NFC" --force "$TMP/t12" 2>&1 >/dev/null) +if [ "$(count_nfd "$TMP/t12")" -eq 0 ] && [ -z "$ferr" ]; then ok "--force: 정상 변환 + 경고 없음"; else ng "--force 이상(err=[$ferr])"; fi # [버전] --version 출력 형식 ver_out=$(/usr/bin/perl "$NFD2NFC" --version 2>&1)