mirror of
https://github.com/jung-geun/NFD2NFC.git
synced 2026-06-21 12:25:14 +09:00
feat(web): GitHub Pages 기반 NFD→NFC 파일명 변환 웹앱 추가
설치 없이 브라우저에서 한글 파일명을 NFC 로 정규화할 수 있는 웹 도구. 파일/폴더 드롭 → 모든 path segment 를 독립적으로 NFC 변환 → fflate 스트리밍 ZIP 다운로드. UTF-8 flag(bit 11) 자동 설정으로 Windows Explorer 에서 정상 표시. 기존 src/core/filter.ts 의 shouldNormalize 를 그대로 재사용. 빌드는 web/ 디렉토리의 별도 Vite 설정으로 격리되어 Electron 빌드에 영향 없음. main 브랜치 push 시 .github/workflows/pages.yml 이 https://jung-geun.github.io/NFD2NFC/ 로 자동 배포. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
94
web/src/lib/buildZip.ts
Normal file
94
web/src/lib/buildZip.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { Zip, ZipPassThrough } from 'fflate';
|
||||
import type { CollectedFile } from './collectFiles';
|
||||
|
||||
export interface BuildProgress {
|
||||
/** 완료된 파일 수. */
|
||||
done: number;
|
||||
/** 전체 파일 수. */
|
||||
total: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* NFC 경로로 정규화된 파일 목록을 스트리밍 방식으로 ZIP 으로 묶는다.
|
||||
*
|
||||
* - fflate 의 Zip + ZipPassThrough 사용. ZipPassThrough 는 deflate 없이 STORE 모드로 묶기 때문에
|
||||
* 사진/영상처럼 이미 압축된 파일이 많은 일반 케이스에서 CPU 를 낭비하지 않는다.
|
||||
* - 파일명은 JS 문자열로 그대로 넘긴다. fflate 는 non-ASCII 가 포함되면 general purpose bit 11
|
||||
* (UTF-8 flag)을 자동으로 켜기 때문에 Windows Explorer 가 CP949 로 오디코딩하지 않는다.
|
||||
* - 한 번에 하나씩 파이프해서 다중 entry 가 동시에 push 되지 않도록 한다 (Zip 은 순차 처리 필요).
|
||||
*/
|
||||
export async function buildZip(
|
||||
files: CollectedFile[],
|
||||
onProgress?: (p: BuildProgress) => void,
|
||||
): Promise<Blob> {
|
||||
const chunks: Uint8Array[] = [];
|
||||
|
||||
const zipDone = new Promise<void>((resolve, reject) => {
|
||||
const zip = new Zip((err, data, final) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
if (data && data.length) chunks.push(data);
|
||||
if (final) resolve();
|
||||
});
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
let done = 0;
|
||||
for (const f of files) {
|
||||
const entry = new ZipPassThrough(f.zipPath);
|
||||
zip.add(entry);
|
||||
await pipeFileToEntry(f.file, entry);
|
||||
done++;
|
||||
onProgress?.({ done, total: files.length });
|
||||
}
|
||||
zip.end();
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
await zipDone;
|
||||
// Uint8Array<ArrayBufferLike> 의 TypeScript 5.7+ 변경 때문에 BlobPart 직접 매칭이 실패할 수 있어 캐스팅.
|
||||
return new Blob(chunks as BlobPart[], { type: 'application/zip' });
|
||||
}
|
||||
|
||||
async function pipeFileToEntry(file: File, entry: ZipPassThrough): Promise<void> {
|
||||
// 일부 브라우저(특히 구버전) 에서 Blob.stream() 이 없을 수 있어 fallback 으로 arrayBuffer 사용.
|
||||
if (typeof file.stream !== 'function') {
|
||||
const buf = new Uint8Array(await file.arrayBuffer());
|
||||
entry.push(buf, true);
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = file.stream().getReader();
|
||||
try {
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
entry.push(new Uint8Array(0), true);
|
||||
return;
|
||||
}
|
||||
if (value && value.length) entry.push(value, false);
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 다운로드 트리거. URL.revokeObjectURL 로 메모리 해제까지 처리.
|
||||
*/
|
||||
export function downloadBlob(blob: Blob, filename: string): void {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
// 같은 microtask 에서 revoke 하면 일부 브라우저에서 다운로드가 취소될 수 있어 약간 지연.
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1_000);
|
||||
}
|
||||
Reference in New Issue
Block a user