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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,15 @@

## [Unreleased]

### Fixed (안정성/스트레스 테스트 후속)
- **셸 glob/탭완성이 인자를 NFC로 정규화하면 변환이 누락되던 문제** — macOS 기본 셸 zsh는 `nfd2nfc *.hwp`에서 파일명을 NFC로 넘겨, 디스크가 NFD인데도 "이미 정규화됨"으로 스킵했다. 최상위 인자를 부모 readdir로 **디스크 실제 저장명**으로 바꿔(`disk_real_path`) 셸 정규화와 무관하게 동작.
- 같은 디스크 항목을 NFD/NFC **다른 철자로 중복 지정**하면 `%seen` 키가 raw 바이트라 dedup이 실패해 카운트 부풀림·이중 rename이 일어나던 문제(디스크 실제명 기준으로 해소).
- NFC로 저장된 파일에 NFD 인자를 주면 실제 변화 없이 "1개 변경"으로 세던 거짓 카운트.
- 아주 깊은 트리에서 Perl `Deep recursion` 경고가 stderr로 새던 문제(`no warnings 'recursion'`).
- `opendir` 실패(권한 없는 하위)·빈/슬래시뿐인 인자도 부분 실패로 집계해 종료 코드 1에 반영.
- `--force`와 `--skip`을 함께 지정하면 우선순위를 경고로 안내(`--skip` 죽은 변수 해소).
- `--help`에 `-`로 시작하는 파일명은 `--` 뒤에 두라는 팁 추가.

### Added
- `-q`/`--quiet` — 요약 출력을 생략하는 조용한 모드(경고·에러는 stderr 유지). cron·스크립트용.
- `-f`/`--force` — 이름 충돌 시 덮어쓰기(기본은 안전하게 건너뜀). `--skip`은 기본값의 명시적 별칭.
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ nfd2nfc -v 보고서.pdf 자료.xlsx # 여러 파일 + 변경 내역 출
- 여러 번 실행해도 안전합니다(idempotent). 바꿀 게 없으면 아무 일도 일어나지 않습니다.
- 깊은 폴더부터 처리해, 폴더 이름을 바꿔도 하위 경로가 어긋나지 않습니다.
- 심볼릭 링크를 따라 들어가지 않습니다.
- 권한이 없어 못 읽거나 못 바꾸는 항목은 건너뛰고 알립니다(종료 코드 1).
- **Dropbox·iCloud 등 동기화 폴더에서도 안전** — 이름만 바꾸므로(메타데이터만 변경), 클라우드 전용(아직 안 받은) 파일을 통째로 내려받지 않습니다.

> 💡 **왜 단순 비교가 아니라 inode를 보냐면** — macOS 파일시스템(APFS·HFS+)은 정규형을 구분하지 않아, NFD 파일을 NFC 이름으로 조회해도 “이미 있다”고 나옵니다. 그래서 nfd2nfc는 단순 존재 검사 대신 inode를 비교해, 정말로 다른 파일이 그 이름을 차지한 경우에만 건너뜁니다.
Expand Down
62 changes: 52 additions & 10 deletions nfd2nfc
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ usage(1) unless @ARGV;

# quiet는 verbose보다 우선(둘 다 주면 조용히). --force가 --skip(기본)을 이긴다.
$verbose = 0 if $quiet;
warn "경고: --force와 --skip을 함께 지정 — --force가 우선합니다.\n" if $force && $skip;

# 사용자가 선택한 최상위 항목(옵션 제거 후 남은 인자) — 완료 후 Finder에 보여줄 대상
my @inputs = @ARGV;
Expand All @@ -84,26 +85,66 @@ nfd2nfc — macOS 한글 파일명 NFD→NFC 변환기
-v, --verbose 변경 내역 출력
-V, --version 버전 출력
-h, --help 이 도움말

팁: '-'로 시작하는 파일명은 -- 뒤에 두세요 (예: nfd2nfc -- -파일.txt)
TXT
exit $code;
}

# 1) 대상 경로 수집 (바이트 문자열 그대로 다룸 = 파일시스템 안전)
my @targets;
my %seen; # 같은 경로 중복 수집 방지(카운트 부풀림·이중 rename 방지)
my $missing = 0; # 존재하지 않는 최상위 입력 개수(종료 코드에 반영)
my %seen; # 같은 디스크 항목 중복 수집 방지(카운트 부풀림·이중 rename 방지)
my $missing = 0; # 존재하지 않는/읽을 수 없는 최상위 입력 개수(종료 코드에 반영)

# 셸(zsh 등)은 glob·탭완성에서 인자를 NFC로 정규화해 넘기기도 한다. 그러면 디스크가 NFD인데도
# 인자 basename이 NFC라 "이미 정규화됨"으로 오인해 변환을 건너뛴다(반대로 NFC 디스크에 NFD 인자를
# 주면 실제 변화 없이 카운트만 오른다). 이를 막기 위해 최상위 인자를 '디스크에 실제로 저장된
# 엔트리명'으로 바꾼다 — 부모 디렉토리를 readdir해 정규화-비구분으로 매칭되는 실제 이름을 찾는다.
sub disk_real_path {
my ($path) = @_;
my $i = rindex($path, '/');
my $base = $i < 0 ? $path : substr($path, $i + 1);
return $path if $base eq '' || $base eq '.' || $base eq '..';
my $dir = $i < 0 ? '.' : (substr($path, 0, $i) eq '' ? '/' : substr($path, 0, $i));
my $u = eval { my $c = $base; decode_utf8($c, Encode::FB_CROAK) };
return $path unless defined $u; # 비-UTF8 인자는 그대로 둔다
my $want = NFC($u);
opendir(my $dh, $dir) or return $path;
while (defined(my $e = readdir $dh)) {
next if $e eq '.' || $e eq '..';
my $eu = eval { my $c = $e; decode_utf8($c, Encode::FB_CROAK) };
next unless defined $eu;
if (NFC($eu) eq $want) { # 정규화-비구분 매칭 = 같은 디스크 항목
closedir $dh;
my $prefix = $i < 0 ? '' : substr($path, 0, $i + 1);
return $prefix . $e; # 디스크에 저장된 실제 바이트(NFD일 수도, NFC일 수도)
}
}
closedir $dh;
return $path; # 매칭 없음(존재 안 함 등) → 원본
}

sub collect {
no warnings 'recursion'; # 아주 깊은 트리에서 'Deep recursion' 경고 억제
my ($path, $toplevel) = @_;
$path =~ s{/+$}{}; # 끝 슬래시 제거
return if $path eq '';
# 사용자가 직접 지정한 최상위 경로가 없으면 알린다(오타 등 조용한 무시 방지).
# APFS는 정규화 비구분이라 NFD로 준 경로도 -e가 잡지만, 정말 없는 경로는 걸러진다.
if ($toplevel && !-e $path && !-l $path) {
warn "경고: 경로가 존재하지 않음: $path\n";
$missing++;
my $orig = $path;
$path =~ s{/+$}{}; # 끝 슬래시 제거
if ($path eq '') {
# 빈 인자나 슬래시뿐인 인자('', '/', '//')는 변환 대상이 아니다(조용한 무시 방지).
if ($toplevel) { warn "경고: 처리할 수 없는 경로: '$orig'\n"; $missing++; }
return;
}
return if $seen{$path}++; # 이미 수집한 경로는 건너뜀
if ($toplevel) {
# 사용자가 직접 지정한 최상위 경로가 없으면 알린다(오타 등 조용한 무시 방지).
# APFS는 정규화 비구분이라 NFD로 준 경로도 -e가 잡지만, 정말 없는 경로는 걸러진다.
if (!-e $path && !-l $path) {
warn "경고: 경로가 존재하지 않음: $path\n";
$missing++;
return;
}
$path = disk_real_path($path); # 셸 정규화와 무관하게 디스크 실제 저장명을 사용
}
return if $seen{$path}++; # 이미 수집한 경로는 건너뜀
push @targets, $path;
if ($recurse && -d $path && !-l $path) {
if (opendir(my $dh, $path)) {
Expand All @@ -115,6 +156,7 @@ sub collect {
}
} else {
warn "열 수 없음: $path ($!)\n";
$missing++; # 읽기 실패도 부분 실패로 exit code에 반영
}
}
}
Expand Down
26 changes: 26 additions & 0 deletions test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,32 @@ 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

# [13] 셸(zsh glob/탭완성)이 NFC로 정규화한 인자로도 디스크 NFD를 변환 (disk_real_path).
# 셸이 인자를 NFC로 넘기면 인자 basename은 NFC지만 디스크 엔트리는 NFD다.
D13="$TMP/t13"; mkdir -p "$D13"
/usr/bin/perl -e 'use utf8;use Unicode::Normalize qw(NFD);use Encode qw(encode_utf8);
open(my$f,">",$ARGV[0]."/".encode_utf8(NFD("계약서.pdf")));close$f;' "$D13"
nfc_arg="$D13/$(/usr/bin/perl -e 'use utf8;use Unicode::Normalize qw(NFC);use Encode qw(encode_utf8);print encode_utf8(NFC("계약서.pdf"))')"
/usr/bin/perl "$NFD2NFC" "$nfc_arg" >/dev/null 2>&1
if [ "$(count_nfd "$D13")" -eq 0 ]; then ok "NFC 인자(셸 정규화)로도 디스크 NFD 변환"; else ng "disk_real_path 실패(NFC 인자 변환 누락)"; fi

# [14] NFC로 저장된 디스크 파일에 NFD 철자 인자 → 실제 변화 없으므로 '0개 변경'(거짓 카운트 방지)
D14="$TMP/t14"; mkdir -p "$D14"
printf x > "$D14/$(/usr/bin/perl -e 'use utf8;use Unicode::Normalize qw(NFC);use Encode qw(encode_utf8);print encode_utf8(NFC("문서.txt"))')"
nfd_arg="$D14/$(/usr/bin/perl -e 'use utf8;use Unicode::Normalize qw(NFD);use Encode qw(encode_utf8);print encode_utf8(NFD("문서.txt"))')"
out14=$(/usr/bin/perl "$NFD2NFC" "$nfd_arg" 2>&1)
if echo "$out14" | grep -q "0개 변경"; then ok "NFC 디스크 + NFD 인자 → 거짓 카운트 없음"; else ng "거짓 카운트: $out14"; fi

# [15] 같은 디스크 항목을 NFD/NFC 다른 철자로 중복 지정 → 카운트 부풀림·이중 처리 없음
D15="$TMP/t15"; mkdir -p "$D15"
/usr/bin/perl -e 'use utf8;use Unicode::Normalize qw(NFD);use Encode qw(encode_utf8);
my$d=$ARGV[0];my$s="$d/".encode_utf8(NFD("폴더"));mkdir $s;
open(my$f,">","$s/".encode_utf8(NFD("파일.txt")));close$f;' "$D15"
nfc_dir="$D15/$(/usr/bin/perl -e 'use utf8;use Unicode::Normalize qw(NFC);use Encode qw(encode_utf8);print encode_utf8(NFC("폴더"))')"
base15=$(/usr/bin/perl "$NFD2NFC" -n "$D15" 2>&1 | tail -1)
dup15=$(/usr/bin/perl "$NFD2NFC" -n "$D15" "$nfc_dir" 2>&1 | tail -1)
if [ "$base15" = "$dup15" ]; then ok "NFD/NFC 철자 중복 입력 dedup(카운트 안 부풀림)"; else ng "dedup 실패: base=[$base15] dup=[$dup15]"; fi

# [버전] --version 출력 형식
ver_out=$(/usr/bin/perl "$NFD2NFC" --version 2>&1)
if echo "$ver_out" | grep -Eq '^nfd2nfc [0-9]+\.[0-9]+\.[0-9]+$'; then ok "--version 출력 형식"; else ng "--version 형식 이상: $ver_out"; fi
Expand Down
Loading