6dce580846
Feature work on top of the initial organizer: - Videos: .mp4/.mov/.avi/.mkv/.webm/.m4v sorted into output/Movie/YYYY/MM - Profiles: reference-image thumbnail cards via a secure photoai-media:// protocol (serves only registered reference images); per-thumbnail delete; add via click, drag & drop, or paste (Ctrl+V) using webUtils.getPathForFile + a byte-based addReferenceData path for clipboard images - Presets: local person library (max 5) saved to userData; one-click apply into active profiles; reusing stored descriptors (no recompute) - Theme: dark/light with dark default (Tailwind class strategy) - i18n: ko/en table-based localization; first-run onboarding to pick language + theme; English-neutral "Unsorted" folder (was [미정]) - App menu: File/Edit/View/Window/Help, localized; Help opens a detailed, theme-aware user guide window - UI: History block scrolls internally (no whole-window scroll); threshold/concurrency tooltips; generic example name - Settings persisted to userData/settings.json; menu + renderer kept in sync - Docs: NextGen Photo AI feasibility review + Phase 0/1 roadmap All typecheck/tests (12) /build green; boot smoke verified. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
188 lines
8.7 KiB
TypeScript
188 lines
8.7 KiB
TypeScript
// 다국어(i18n) 문자열 테이블. ko/en 두 언어를 지원한다.
|
||
// 메인(메뉴/가이드)과 렌더러(UI)가 공용으로 사용한다.
|
||
|
||
export type Lang = 'ko' | 'en'
|
||
|
||
export const LANGS: Lang[] = ['ko', 'en']
|
||
export const DEFAULT_LANG: Lang = 'ko'
|
||
|
||
export const LANG_LABEL: Record<Lang, string> = {
|
||
ko: '한국어',
|
||
en: 'English'
|
||
}
|
||
|
||
/** 키 → 언어별 문자열. {token} 형태의 치환자를 지원. */
|
||
type Table = Record<string, Record<Lang, string>>
|
||
|
||
export const MESSAGES: Table = {
|
||
// 공통
|
||
'app.title': { ko: 'AI Photo Organizer', en: 'AI Photo Organizer' },
|
||
'app.subtitle': {
|
||
ko: '얼굴 인식 + 촬영일 기준 자동 사진 정리 · 로컬 전용',
|
||
en: 'Auto-organize photos by face recognition + capture date · Local only'
|
||
},
|
||
'common.add': { ko: '추가', en: 'Add' },
|
||
|
||
// 온보딩
|
||
'onboard.title': { ko: '시작하기 전에', en: 'Before you start' },
|
||
'onboard.subtitle': {
|
||
ko: '언어와 테마를 선택하세요. 나중에 메뉴에서 바꿀 수 있습니다.',
|
||
en: 'Choose your language and theme. You can change these later from the menu.'
|
||
},
|
||
'onboard.language': { ko: '언어', en: 'Language' },
|
||
'onboard.theme': { ko: '테마', en: 'Theme' },
|
||
'onboard.dark': { ko: '다크', en: 'Dark' },
|
||
'onboard.light': { ko: '라이트', en: 'Light' },
|
||
'onboard.start': { ko: '시작하기', en: 'Get Started' },
|
||
|
||
// 1. 프로필
|
||
'profile.section': { ko: '1. 인물 프로필', en: '1. Person Profiles' },
|
||
'profile.count': {
|
||
ko: '{n}/{max}명 · 순서 = 이동 우선순위',
|
||
en: '{n}/{max} people · order = move priority'
|
||
},
|
||
'profile.namePlaceholder': {
|
||
ko: '인물 이름 (예: Alex)',
|
||
en: 'Person name (e.g. Alex)'
|
||
},
|
||
'profile.dndHint': {
|
||
ko: '타일 클릭 · 드래그&드롭 · 붙여넣기(Ctrl+V)로 추가',
|
||
en: 'Add by click, drag & drop, or paste (Ctrl+V)'
|
||
},
|
||
'profile.empty': {
|
||
ko: '등록된 프로필이 없습니다. 이름을 추가하고 참조 얼굴 사진을 등록하세요.',
|
||
en: 'No profiles yet. Add a name and register reference face photos.'
|
||
},
|
||
'profile.refCount': { ko: '참조 {n}장', en: '{n} refs' },
|
||
'profile.delete': { ko: '프로필 삭제', en: 'Delete profile' },
|
||
'profile.savePreset': { ko: '프리셋 저장', en: 'Save preset' },
|
||
'profile.presets': { ko: '프리셋', en: 'Presets' },
|
||
'profile.presetsHint': {
|
||
ko: '자주 쓰는 인물을 저장해두고 클릭으로 불러옵니다 (로컬 전용)',
|
||
en: 'Save people you use often and load them with a click (local only)'
|
||
},
|
||
'profile.presetsEmpty': {
|
||
ko: "저장된 프리셋이 없습니다. 프로필의 '프리셋 저장'으로 추가하세요.",
|
||
en: "No presets yet. Use 'Save preset' on a profile."
|
||
},
|
||
'profile.applyPresetTitle': {
|
||
ko: '클릭해서 인물 프로필에 추가',
|
||
en: 'Click to add to profiles'
|
||
},
|
||
'profile.deletePreset': { ko: '프리셋 삭제', en: 'Delete preset' },
|
||
'profile.presetsFull': {
|
||
ko: '프리셋이 가득 찼습니다 (최대 {max}개).',
|
||
en: 'Presets are full (max {max}).'
|
||
},
|
||
'profile.profilesFull': {
|
||
ko: '프로필이 가득 찼습니다 (최대 {max}명).',
|
||
en: 'Profiles are full (max {max}).'
|
||
},
|
||
'profile.addFace': { ko: '얼굴 추가', en: 'Add face' },
|
||
'profile.analyzing': { ko: '분석 중', en: 'Analyzing' },
|
||
'profile.deletePhoto': { ko: '이 사진 삭제', en: 'Delete this photo' },
|
||
|
||
// 2. 폴더
|
||
'folder.section': { ko: '2. 폴더 선택', en: '2. Select Folders' },
|
||
'folder.source': { ko: '정리할 폴더 (소스)', en: 'Folder to organize (source)' },
|
||
'folder.output': { ko: '결과 저장 폴더 (출력)', en: 'Output folder' },
|
||
'folder.unselected': { ko: '미선택', en: 'Not selected' },
|
||
'folder.browse': { ko: '찾기', en: 'Browse' },
|
||
|
||
// 3. 실행 옵션
|
||
'run.section': { ko: '3. 실행 옵션', en: '3. Run Options' },
|
||
'run.threshold': { ko: '매칭 임계값 ({v})', en: 'Match threshold ({v})' },
|
||
'run.thresholdHint': { ko: '낮을수록 엄격', en: 'Lower = stricter' },
|
||
'run.thresholdTip': {
|
||
ko: '두 얼굴이 같은 사람인지 판단하는 거리 기준입니다.\n• 낮추면(예: 0.4) 더 엄격 → 다른 사람이 섞일 확률↓, 대신 본인을 놓칠 수 있음\n• 높이면(예: 0.6) 더 너그러움 → 본인을 잘 찾음, 대신 다른 사람이 섞일 수 있음\n권장: 0.45~0.55',
|
||
en: 'Distance threshold for deciding whether two faces are the same person.\n• Lower (e.g. 0.4) = stricter → fewer false matches, but may miss the person\n• Higher (e.g. 0.6) = looser → catches the person more, but may include others\nRecommended: 0.45–0.55'
|
||
},
|
||
'run.concurrency': { ko: '동시 처리 ({v})', en: 'Concurrency ({v})' },
|
||
'run.concurrencyTip': {
|
||
ko: '한 번에 동시에 처리하는 파일 수입니다.\n• 높이면 처리 속도↑, 대신 메모리(RAM)·CPU 사용량↑\n• 사양이 낮거나 고해상도 사진이 많으면 2~3 권장',
|
||
en: 'How many files are processed at the same time.\n• Higher = faster, but uses more memory (RAM) and CPU\n• On lower-end machines or with many high-resolution photos, 2–3 is recommended'
|
||
},
|
||
'run.detector': { ko: '검출 엔진', en: 'Detection engine' },
|
||
'run.detectorSsd': {
|
||
ko: '정확도 우선 (SSD MobileNet)',
|
||
en: 'Accuracy first (SSD MobileNet)'
|
||
},
|
||
'run.detectorTiny': { ko: '속도 우선 (Tiny Face)', en: 'Speed first (Tiny Face)' },
|
||
'run.noFaceWarn': {
|
||
ko: "⚠️ 등록된 얼굴이 없습니다. 매칭 인물 없이 모두 'Unsorted' 폴더로 분류됩니다.",
|
||
en: "⚠️ No registered faces. Everything will be sorted into the 'Unsorted' folder."
|
||
},
|
||
'run.start': { ko: '정리 시작', en: 'Start' },
|
||
'run.rerun': { ko: '다시 실행', en: 'Run again' },
|
||
'run.cancel': { ko: '취소', en: 'Cancel' },
|
||
'run.reset': { ko: '초기화', en: 'Reset' },
|
||
|
||
// 진행/결과
|
||
'progress.section': { ko: '진행 상황', en: 'Progress' },
|
||
'progress.waiting': { ko: '대기 중', en: 'Waiting' },
|
||
'progress.scanning': { ko: '스캔 중…', en: 'Scanning…' },
|
||
'progress.idle': { ko: '실행 대기', en: 'Idle' },
|
||
|
||
'history.section': { ko: '처리 내역', en: 'History' },
|
||
'history.recent': { ko: '최근 {n}건', en: 'Recent {n}' },
|
||
'history.empty': { ko: '아직 처리된 파일이 없습니다.', en: 'No files processed yet.' },
|
||
|
||
'kind.moved': { ko: '이동', en: 'Moved' },
|
||
'kind.copied': { ko: '복사', en: 'Copied' },
|
||
'kind.unmatched': { ko: '미정', en: 'Unsorted' },
|
||
'kind.movie': { ko: '영상', en: 'Video' },
|
||
'kind.failed': { ko: '실패', en: 'Failed' },
|
||
|
||
'report.done': { ko: '✅ 작업 완료', en: '✅ Done' },
|
||
'report.elapsed': { ko: '소요 {d}', en: 'Took {d}' },
|
||
'report.total': { ko: '총 처리', en: 'Total' },
|
||
'report.log': { ko: '로그: {path}', en: 'Log: {path}' },
|
||
'report.errors': { ko: '오류 {n}건 보기', en: 'View {n} errors' },
|
||
'dur.ms': { ko: '{m}분 {s}초', en: '{m}m {s}s' },
|
||
'dur.s': { ko: '{s}초', en: '{s}s' },
|
||
|
||
// 메뉴
|
||
'menu.file': { ko: '파일', en: 'File' },
|
||
'menu.edit': { ko: '편집', en: 'Edit' },
|
||
'menu.view': { ko: '보기', en: 'View' },
|
||
'menu.window': { ko: '창', en: 'Window' },
|
||
'menu.help': { ko: '도움말', en: 'Help' },
|
||
'menu.quit': { ko: '종료', en: 'Quit' },
|
||
'menu.undo': { ko: '실행 취소', en: 'Undo' },
|
||
'menu.redo': { ko: '다시 실행', en: 'Redo' },
|
||
'menu.cut': { ko: '잘라내기', en: 'Cut' },
|
||
'menu.copy': { ko: '복사', en: 'Copy' },
|
||
'menu.paste': { ko: '붙여넣기', en: 'Paste' },
|
||
'menu.selectall': { ko: '모두 선택', en: 'Select All' },
|
||
'menu.reload': { ko: '새로고침', en: 'Reload' },
|
||
'menu.devtools': { ko: '개발자 도구', en: 'Toggle DevTools' },
|
||
'menu.resetzoom': { ko: '실제 크기', en: 'Actual Size' },
|
||
'menu.zoomin': { ko: '확대', en: 'Zoom In' },
|
||
'menu.zoomout': { ko: '축소', en: 'Zoom Out' },
|
||
'menu.fullscreen': { ko: '전체 화면', en: 'Toggle Full Screen' },
|
||
'menu.minimize': { ko: '최소화', en: 'Minimize' },
|
||
'menu.close': { ko: '닫기', en: 'Close' },
|
||
'menu.appearance': { ko: '테마', en: 'Appearance' },
|
||
'menu.theme.dark': { ko: '다크 모드', en: 'Dark' },
|
||
'menu.theme.light': { ko: '라이트 모드', en: 'Light' },
|
||
'menu.language': { ko: '언어', en: 'Language' },
|
||
'menu.guide': { ko: '사용 방법 가이드', en: 'User Guide' },
|
||
'menu.about': { ko: '정보', en: 'About' }
|
||
}
|
||
|
||
/** 번역. 누락 키는 키 자체를 반환(개발 중 가시성). {token} 치환 지원. */
|
||
export function translate(
|
||
lang: Lang,
|
||
key: string,
|
||
params?: Record<string, string | number>
|
||
): string {
|
||
const entry = MESSAGES[key]
|
||
let text = entry ? entry[lang] : key
|
||
if (params) {
|
||
for (const [k, v] of Object.entries(params)) {
|
||
text = text.replace(new RegExp(`\\{${k}\\}`, 'g'), String(v))
|
||
}
|
||
}
|
||
return text
|
||
}
|