From 1a7d9572d19ec1fbc882ffaa7db89237b82d7934 Mon Sep 17 00:00:00 2001
From: Wonjun Choi <99017069+wonjun-lab@users.noreply.github.com>
Date: Tue, 2 Jun 2026 13:15:47 +0900
Subject: [PATCH 1/3] =?UTF-8?q?feat(watch):=20nfd2nfc=20watch=20=EC=9E=90?=
=?UTF-8?q?=EB=8F=99=20=EA=B0=90=EC=8B=9C=20=EC=84=9C=EB=B8=8C=EC=BB=A4?=
=?UTF-8?q?=EB=A7=A8=EB=93=9C?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
launchd WatchPaths로 등록 폴더를 감시해 NFD 파일 유입 시 자동 NFC 정리.
- watch add/remove/list/on/off + 내부 __run
- 목록(~/Library/Application Support/nfd2nfc/folders.list), plist(LaunchAgent),
로그(~/Library/Logs/nfd2nfc-watch.log)
- __run은 코어를 리스트폼으로 각 폴더에 재호출(셸 미경유), 변경>0이면 알림
- 무한루프: idempotent + ThrottleInterval 10초. 의존성 0, 옵트인.
- 상단 디스패치(exit watch_main)가 먼저 실행되므로 라벨은 my가 아닌 sub로 정의.
---
nfd2nfc | 194 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 194 insertions(+)
diff --git a/nfd2nfc b/nfd2nfc
index bf7c7ce..4f305b9 100755
--- a/nfd2nfc
+++ b/nfd2nfc
@@ -37,6 +37,12 @@ use Getopt::Long qw(GetOptions);
our $VERSION = "1.0.1";
+# 첫 인자가 'watch'면 자동 감시 서브커맨드로 분기(옵션 파싱 전에 가로챈다).
+if (@ARGV && $ARGV[0] eq 'watch') {
+ shift @ARGV;
+ exit watch_main(@ARGV);
+}
+
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)가 섞이지 않도록.
@@ -270,3 +276,191 @@ sub reveal_in_finder {
}
system('/usr/bin/osascript', '-e', $script);
}
+
+# ── 자동 감시(watch) 서브커맨드 ──────────────────────────────────────────────
+# 등록한 폴더를 launchd(WatchPaths)가 감시하다가 변경 시 'watch __run'을 실행해
+# 코어 변환을 각 폴더에 재호출한다. 의존성 0(perl + launchd), 옵트인.
+use Cwd ();
+
+# 상단 디스패치(exit watch_main)가 런타임에 먼저 실행되므로, 파일 끝 my 변수는
+# 그 시점에 미초기화다. 따라서 상수는 my가 아니라 sub로 둔다(런타임 순서 무관).
+sub watch_label { 'com.wonjun-lab.nfd2nfc.watch' }
+sub watch_support_dir { "$ENV{HOME}/Library/Application Support/nfd2nfc" }
+sub watch_list_file { watch_support_dir() . "/folders.list" }
+sub watch_plist_file { "$ENV{HOME}/Library/LaunchAgents/" . watch_label() . ".plist" }
+sub watch_log_file { "$ENV{HOME}/Library/Logs/nfd2nfc-watch.log" }
+
+sub watch_usage {
+ print <<'TXT';
+nfd2nfc watch — 폴더 자동 NFC 감시
+
+사용법:
+ nfd2nfc watch add <폴더 ...> 감시 폴더 등록(+즉시 1회 정리)
+ nfd2nfc watch remove <폴더 ...> 등록 해제
+ nfd2nfc watch list 등록 폴더·상태 보기
+ nfd2nfc watch on | off 감시 재개 / 일시 중지
+TXT
+}
+
+sub watch_main {
+ my ($cmd, @args) = @_;
+ $cmd //= '';
+ return watch_add(@args) if $cmd eq 'add';
+ return watch_remove(@args) if $cmd eq 'remove';
+ return watch_list() if $cmd eq 'list';
+ return watch_on() if $cmd eq 'on';
+ return watch_off() if $cmd eq 'off';
+ return watch_run() if $cmd eq '__run';
+ watch_usage();
+ return 1;
+}
+
+# ── 목록 I/O ──
+sub watch_read_list {
+ my $f = watch_list_file();
+ return () unless -f $f;
+ open(my $fh, '<', $f) or return ();
+ my @dirs = grep { length } map { my $l = $_; $l =~ s/\s+$//; $l } <$fh>;
+ close $fh;
+ return @dirs;
+}
+sub watch_write_list {
+ my @dirs = @_;
+ system('/bin/mkdir', '-p', watch_support_dir());
+ open(my $fh, '>', watch_list_file()) or die "목록을 쓸 수 없음: $!\n";
+ print $fh "$_\n" for @dirs;
+ close $fh;
+}
+# ~ 확장 + 절대경로(존재하는 경로만, 아니면 undef)
+sub watch_abs {
+ my ($p) = @_;
+ $p =~ s{^~(/|$)}{$ENV{HOME}$1};
+ return Cwd::abs_path($p);
+}
+
+# ── plist 생성 ──
+sub xml_esc { my $s = shift; $s =~ s/&/&/g; $s =~ s/</g; $s =~ s/>/>/g; return $s; }
+sub watch_write_plist {
+ my @dirs = watch_read_list();
+ my $self = Cwd::abs_path($0) // $0;
+ my $log = watch_log_file();
+ my $paths = join "\n", map { "\t\t" . xml_esc($_) . "" } @dirs;
+ my $body = <<"PLIST";
+
+
+
+
+\tLabel
+\t@{[ xml_esc(watch_label()) ]}
+\tProgramArguments
+\t
+\t\t@{[ xml_esc($self) ]}
+\t\twatch
+\t\t__run
+\t
+\tWatchPaths
+\t
+$paths
+\t
+\tThrottleInterval
+\t10
+\tStandardOutPath
+\t@{[ xml_esc($log) ]}
+\tStandardErrorPath
+\t@{[ xml_esc($log) ]}
+\tRunAtLoad
+\t
+
+
+PLIST
+ system('/bin/mkdir', '-p', "$ENV{HOME}/Library/LaunchAgents");
+ open(my $fh, '>', watch_plist_file()) or die "plist를 쓸 수 없음: $!\n";
+ print $fh $body;
+ close $fh;
+}
+
+# ── launchctl (NFD2NFC_WATCH_NO_LAUNCHCTL=1이면 건너뜀 — 테스트/CI용) ──
+sub watch_load {
+ return if $ENV{NFD2NFC_WATCH_NO_LAUNCHCTL};
+ my $p = watch_plist_file();
+ return unless -f $p;
+ system('/bin/launchctl', 'unload', $p); # 기존 로드 정리(실패 무시)
+ system('/bin/launchctl', 'load', $p);
+}
+sub watch_unload {
+ return if $ENV{NFD2NFC_WATCH_NO_LAUNCHCTL};
+ system('/bin/launchctl', 'unload', watch_plist_file());
+}
+sub watch_is_loaded {
+ return 0 if $ENV{NFD2NFC_WATCH_NO_LAUNCHCTL};
+ return (system('/bin/launchctl', 'list', watch_label()) == 0) ? 1 : 0;
+}
+
+# ── 실행(__run): 등록 폴더 전체 정리 + 로그 + 변경시 알림 ──
+sub watch_log {
+ my ($msg) = @_;
+ system('/bin/mkdir', '-p', "$ENV{HOME}/Library/Logs");
+ open(my $fh, '>>', watch_log_file()) or return;
+ my @t = localtime;
+ printf $fh "[%04d-%02d-%02d %02d:%02d:%02d] %s\n",
+ $t[5] + 1900, $t[4] + 1, $t[3], $t[2], $t[1], $t[0], $msg;
+ close $fh;
+}
+sub watch_run {
+ my $self = Cwd::abs_path($0) // $0;
+ my @dirs = grep { -d $_ } watch_read_list();
+ my $total = 0;
+ for my $d (@dirs) {
+ # 리스트폼 파이프 → 셸 미경유(경로 특수문자 안전). 코어를 일반 모드로 호출해 요약 캡처.
+ my @cmd = ($self, '--', $d);
+ if (open(my $ph, '-|', @cmd)) {
+ local $/;
+ my $out = <$ph>;
+ close $ph;
+ if (defined $out && $out =~ /완료:\s*(\d+)개 변경/) { $total += $1; }
+ }
+ }
+ watch_log("run: ${total}개 변경 (" . scalar(@dirs) . "개 폴더)");
+ if ($total > 0 && !$ENV{NFD2NFC_NO_GUI}) {
+ my $msg = "${total}개 파일 이름을 NFC로 자동 정리했습니다";
+ system('/usr/bin/osascript', '-e',
+ qq{display notification "$msg" with title "nfd2nfc 자동 감시"});
+ }
+ return 0;
+}
+
+# ── 사용자 명령 ──
+sub watch_add {
+ my @new;
+ for my $arg (@_) {
+ my $abs = watch_abs($arg);
+ if (!defined $abs || !-d $abs) { warn "폴더가 아니거나 없음: $arg\n"; next; }
+ push @new, $abs;
+ }
+ return 1 unless @new;
+ my %seen; my @dirs = grep { !$seen{$_}++ } (watch_read_list(), @new);
+ watch_write_list(@dirs);
+ watch_write_plist();
+ watch_load();
+ watch_run();
+ print "감시 등록: ", join(", ", @new), "\n";
+ return 0;
+}
+sub watch_remove {
+ my %rm = map { my $a = watch_abs($_) // $_; ($a => 1) } @_;
+ my @dirs = grep { !$rm{$_} } watch_read_list();
+ watch_write_list(@dirs);
+ if (@dirs) { watch_write_plist(); watch_load(); }
+ else { watch_unload(); unlink watch_plist_file(); }
+ print "감시 해제 완료. 남은 폴더: ", scalar(@dirs), "개\n";
+ return 0;
+}
+sub watch_list {
+ my @dirs = watch_read_list();
+ my $active = watch_is_loaded() ? "활성" : "중지";
+ print "자동 감시: ", (@dirs ? "$active · 폴더 " . scalar(@dirs) . "개" : "등록된 폴더 없음"), "\n";
+ print " • $_\n" for @dirs;
+ return 0;
+}
+sub watch_on { watch_write_plist(); watch_load(); print "감시 재개\n"; return 0; }
+sub watch_off { watch_unload(); print "감시 중지\n"; return 0; }
From 39f37aa69e69bdba529e0b3a031ca206ca09954b Mon Sep 17 00:00:00 2001
From: Wonjun Choi <99017069+wonjun-lab@users.noreply.github.com>
Date: Tue, 2 Jun 2026 13:15:47 +0900
Subject: [PATCH 2/3] =?UTF-8?q?test(watch):=20watch=20=ED=86=B5=ED=95=A9?=
=?UTF-8?q?=20=ED=85=8C=EC=8A=A4=ED=8A=B8=206=EC=A2=85?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
usage·목록 add/list/remove·plist plutil 유효성·on/off·__run 정리+로그(무한루프 가드)·미존재 폴더 견고성. HOME=$TMP/home 격리, launchctl은 NFD2NFC_WATCH_NO_LAUNCHCTL로 건너뜀. 통과 25/25.
---
test.sh | 47 +++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 47 insertions(+)
diff --git a/test.sh b/test.sh
index f4c7f87..892fb1f 100755
--- a/test.sh
+++ b/test.sh
@@ -221,6 +221,53 @@ before16=$(entry_bytes "$D16"); md5b16=$(md5 -q "$D16"/*.zip)
after16=$(entry_bytes "$D16"); md5a16=$(md5 -q "$D16"/*.zip)
if [ "$before16" = "$after16" ] && [ "$md5b16" = "$md5a16" ]; then ok "압축 내부 엔트리명·내용 불변(스코프 밖)"; else ng "압축 내부 변함: name $before16→$after16"; fi
+# ── watch(자동 감시) 케이스 — HOME=$TMP/home 격리, launchctl은 환경변수로 건너뜀 ──
+mknfd_in() { /usr/bin/perl -e 'use utf8;use Unicode::Normalize qw(NFD);use Encode qw(encode_utf8);open(my$f,">",$ARGV[0]."/".encode_utf8(NFD($ARGV[1])));close$f;' "$1" "$2"; }
+
+# [w1] 알 수 없는 watch 하위명령 → usage + 비0
+wout=$(HOME="$TMP/home" /usr/bin/perl "$NFD2NFC" watch bogus 2>&1); wrc=$?
+if echo "$wout" | grep -q "watch" && [ "$wrc" -ne 0 ]; then ok "watch usage(잘못된 하위명령)"; else ng "watch usage 이상: $wout"; fi
+
+# [w2] add → list 절대경로 dedup, remove로 제거
+WH="$TMP/home"; rm -rf "$WH"; mkdir -p "$WH" "$TMP/wf1" "$TMP/wf2"
+HOME="$WH" NFD2NFC_WATCH_NO_LAUNCHCTL=1 /usr/bin/perl "$NFD2NFC" watch add "$TMP/wf1" "$TMP/wf1" "$TMP/wf2" >/dev/null 2>&1
+n=$(HOME="$WH" /usr/bin/perl "$NFD2NFC" watch list 2>/dev/null | grep -c "$TMP/wf")
+HOME="$WH" NFD2NFC_WATCH_NO_LAUNCHCTL=1 /usr/bin/perl "$NFD2NFC" watch remove "$TMP/wf1" >/dev/null 2>&1
+n2=$(HOME="$WH" /usr/bin/perl "$NFD2NFC" watch list 2>/dev/null | grep -c "$TMP/wf")
+if [ "$n" -eq 2 ] && [ "$n2" -eq 1 ]; then ok "watch add/list/remove 목록 관리"; else ng "watch 목록 이상: n=$n n2=$n2"; fi
+
+# [w3] plist 생성 + plutil 유효성
+WH="$TMP/home"; rm -rf "$WH"; mkdir -p "$WH" "$TMP/wp1"
+HOME="$WH" NFD2NFC_WATCH_NO_LAUNCHCTL=1 /usr/bin/perl "$NFD2NFC" watch add "$TMP/wp1" >/dev/null 2>&1
+PL="$WH/Library/LaunchAgents/com.wonjun-lab.nfd2nfc.watch.plist"
+if [ -f "$PL" ] && /usr/bin/plutil -lint "$PL" >/dev/null 2>&1 && grep -q "$TMP/wp1" "$PL"; then ok "watch plist 생성·유효성"; else ng "watch plist 이상"; fi
+
+# [w4] on/off 상태 메시지(실제 launchctl은 게이트)
+WH="$TMP/home"; rm -rf "$WH"; mkdir -p "$WH" "$TMP/wo1"
+HOME="$WH" NFD2NFC_WATCH_NO_LAUNCHCTL=1 /usr/bin/perl "$NFD2NFC" watch add "$TMP/wo1" >/dev/null 2>&1
+offm=$(HOME="$WH" NFD2NFC_WATCH_NO_LAUNCHCTL=1 /usr/bin/perl "$NFD2NFC" watch off 2>&1)
+onm=$(HOME="$WH" NFD2NFC_WATCH_NO_LAUNCHCTL=1 /usr/bin/perl "$NFD2NFC" watch on 2>&1)
+if echo "$offm" | grep -q "중지" && echo "$onm" | grep -q "재개"; then ok "watch on/off 상태"; else ng "watch on/off 이상: $offm / $onm"; fi
+
+# [w5] __run이 등록 폴더 전체 정리(add 즉시 1회) + 로그, 재실행 0개(무한루프 가드)
+WH="$TMP/home"; rm -rf "$WH"; mkdir -p "$WH"
+RA="$TMP/wr1"; RB="$TMP/wr2"; mkdir -p "$RA" "$RB"
+mknfd_in "$RA" "보고서.hwp"; mknfd_in "$RB" "사진.jpg"
+HOME="$WH" NFD2NFC_NO_GUI=1 NFD2NFC_WATCH_NO_LAUNCHCTL=1 /usr/bin/perl "$NFD2NFC" watch add "$RA" "$RB" >/dev/null 2>&1
+left=$(( $(count_nfd "$RA") + $(count_nfd "$RB") ))
+HOME="$WH" NFD2NFC_NO_GUI=1 NFD2NFC_WATCH_NO_LAUNCHCTL=1 /usr/bin/perl "$NFD2NFC" watch __run >/dev/null 2>&1
+log_ok=$(grep -c "run:" "$WH/Library/Logs/nfd2nfc-watch.log" 2>/dev/null || echo 0)
+if [ "$left" -eq 0 ] && [ "${log_ok:-0}" -ge 1 ]; then ok "watch __run 정리 + 로그(무한루프 가드)"; else ng "watch __run 이상: left=$left log=$log_ok"; fi
+
+# [w6] 등록 폴더가 사라져도 __run이 깨지지 않고 살아있는 폴더만 정리
+WH="$TMP/home"; rm -rf "$WH"; mkdir -p "$WH"
+GA="$TMP/wg1"; GB="$TMP/wg2"; mkdir -p "$GA" "$GB"
+HOME="$WH" NFD2NFC_NO_GUI=1 NFD2NFC_WATCH_NO_LAUNCHCTL=1 /usr/bin/perl "$NFD2NFC" watch add "$GA" "$GB" >/dev/null 2>&1
+rm -rf "$GB"
+mknfd_in "$GA" "새.txt"
+HOME="$WH" NFD2NFC_NO_GUI=1 NFD2NFC_WATCH_NO_LAUNCHCTL=1 /usr/bin/perl "$NFD2NFC" watch __run >/dev/null 2>&1; rcrun=$?
+if [ "$rcrun" -eq 0 ] && [ "$(count_nfd "$GA")" -eq 0 ]; then ok "watch __run 미존재 폴더 견고성"; else ng "watch __run 견고성 이상: rc=$rcrun"; 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
From 2553ef273298f687659fcbbd89be03b928628e78 Mon Sep 17 00:00:00 2001
From: Wonjun Choi <99017069+wonjun-lab@users.noreply.github.com>
Date: Tue, 2 Jun 2026 13:15:47 +0900
Subject: [PATCH 3/3] =?UTF-8?q?docs(watch):=20=EC=9E=90=EB=8F=99=20?=
=?UTF-8?q?=EA=B0=90=EC=8B=9C=20=EC=82=AC=EC=9A=A9=EB=B2=95=20+=20CHANGELO?=
=?UTF-8?q?G?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
README에 'watch' 섹션(사용법·로그·켜고 끄기), CHANGELOG Unreleased에 자동 감시 기록.
---
CHANGELOG.md | 3 +++
README.md | 18 ++++++++++++++++++
2 files changed, 21 insertions(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b56e116..4958aff 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,9 @@
## [Unreleased]
+### Added (자동 감시)
+- `nfd2nfc watch add/remove/list/on/off` — launchd `WatchPaths`로 등록 폴더를 감시해 NFD 파일 유입 시 자동으로 NFC 정리. 등록 시 즉시 1회 정리, 하위 폴더 포함, 변경 있을 때만 알림. 의존성 0(perl + launchd), 옵트인. 무한루프는 idempotent + `ThrottleInterval` 10초로 차단. 설정 `~/Library/Application Support/nfd2nfc/`, 로그 `~/Library/Logs/nfd2nfc-watch.log`.
+
### Changed (형식/FS 검토 후속)
- `--force`가 실제로 **다른 항목을 덮어쓸 때 경고**를 출력(`덮어씀(기존 항목 영구 삭제)`) — loud opt-in. 충돌은 정규화-구분 FS에서만 발생하므로 macOS 기본 볼륨에선 무영향.
- README에 **"바꾸는 범위" 섹션** 추가 — 파일 내용·형식 무관, **압축 파일 내부 엔트리명은 스코프 밖**(zip/tar/hwpx/docx), `.app` 번들 서명 주의, `--force` 데이터 손실은 정규화-구분 볼륨 한정임을 명시.
diff --git a/README.md b/README.md
index 3e59eef..ebb9e29 100644
--- a/README.md
+++ b/README.md
@@ -117,6 +117,24 @@ nfd2nfc -v 보고서.pdf 자료.xlsx # 여러 파일 + 변경 내역 출
---
+##
자동 감시 — 폴더를 알아서 정리
+
+자주 NFD 파일이 들어오는 폴더(다운로드·Dropbox 등)를 등록해 두면, 새 파일이 생길 때마다 백그라운드에서 자동으로 NFC로 정리합니다. macOS 기본 `launchd`만 쓰며(의존성 0), 변경이 있을 때만 조용히 알립니다.
+
+```sh
+nfd2nfc watch add ~/Downloads ~/Dropbox # 등록 + 즉시 1회 정리
+nfd2nfc watch list # 등록 폴더·상태 보기
+nfd2nfc watch off # 잠시 중지
+nfd2nfc watch on # 재개
+nfd2nfc watch remove ~/Dropbox # 해제
+```
+
+- 등록 폴더는 **하위까지** 정리합니다. 무한루프 없이(idempotent + 10초 간격) 안전하게 동작합니다.
+- 로그: `~/Library/Logs/nfd2nfc-watch.log` · 설정: `~/Library/Application Support/nfd2nfc/`
+- 켜고 끄기는 로그인 세션 단위로 유지됩니다(launchd LaunchAgent).
+
+---
+
##
안전한가요?
네. 보수적으로, 되돌릴 일이 없게 동작합니다.