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; 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). + +--- + ## 안전한가요? 네. 보수적으로, 되돌릴 일이 없게 동작합니다.