Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
29 changes: 29 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` | 도움말 |
Expand Down Expand Up @@ -218,7 +221,7 @@ characters stay the same; only the underlying Unicode form is normalized.
| <img src="https://api.iconify.design/ph/terminal-window-bold.svg?color=%236E7DF2&width=18" width="18" align="center" /> CLI + Finder Quick Action | `git clone … && cd nfd2nfc && ./install.sh` |

```
nfd2nfc [--dry-run] [--no-recurse] [--notify] [-v] [-V] <paths…>
nfd2nfc [--dry-run] [--no-recurse] [--notify] [--reveal] [-q] [-f] [-v] [-V] [-h] <paths…>
```

Safe by design: already-NFC files are left untouched, clashes with genuinely different
Expand Down
33 changes: 29 additions & 4 deletions build-workflow.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -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 <string> 안에 들어가므로 XML 특수문자 이스케이프
my $esc = $cmd;
Expand Down Expand Up @@ -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 유효성 검사
Expand Down
41 changes: 28 additions & 13 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
58 changes: 49 additions & 9 deletions nfd2nfc
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
Expand All @@ -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;

Expand All @@ -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 이 도움말
Expand All @@ -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)) {
Expand All @@ -87,7 +118,7 @@ sub collect {
}
}
}
collect($_) for @ARGV;
collect($_, 1) for @ARGV;

# 2) 깊은 경로부터 처리(자식 → 부모) 해야 폴더 이름을 바꿔도 경로가 안 깨짐
my @sorted = sort { ($b =~ tr{/}{}) <=> ($a =~ tr{/}{}) } @targets;
Expand All @@ -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;
Expand All @@ -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',
Expand All @@ -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{/+$}{};
Expand All @@ -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 = @_;
Expand Down
9 changes: 9 additions & 0 deletions nfd2nfc.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading