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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` 데이터 손실은 정규화-구분 볼륨 한정임을 명시.
Expand Down
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,24 @@ nfd2nfc -v 보고서.pdf 자료.xlsx # 여러 파일 + 변경 내역 출

---

## <img src="https://api.iconify.design/ph/eye-bold.svg?color=%236E7DF2&width=24" width="24" /> 자동 감시 — 폴더를 알아서 정리

자주 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).

---

## <img src="https://api.iconify.design/ph/shield-check-bold.svg?color=%236E7DF2&width=24" width="24" /> 안전한가요?

네. 보수적으로, 되돌릴 일이 없게 동작합니다.
Expand Down
194 changes: 194 additions & 0 deletions nfd2nfc
Original file line number Diff line number Diff line change
Expand Up @@ -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)가 섞이지 않도록.
Expand Down Expand Up @@ -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/&/&amp;/g; $s =~ s/</&lt;/g; $s =~ s/>/&gt;/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<string>" . xml_esc($_) . "</string>" } @dirs;
my $body = <<"PLIST";
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
\t<key>Label</key>
\t<string>@{[ xml_esc(watch_label()) ]}</string>
\t<key>ProgramArguments</key>
\t<array>
\t\t<string>@{[ xml_esc($self) ]}</string>
\t\t<string>watch</string>
\t\t<string>__run</string>
\t</array>
\t<key>WatchPaths</key>
\t<array>
$paths
\t</array>
\t<key>ThrottleInterval</key>
\t<integer>10</integer>
\t<key>StandardOutPath</key>
\t<string>@{[ xml_esc($log) ]}</string>
\t<key>StandardErrorPath</key>
\t<string>@{[ xml_esc($log) ]}</string>
\t<key>RunAtLoad</key>
\t<false/>
</dict>
</plist>
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; }
47 changes: 47 additions & 0 deletions test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading