Birthday & anniversary photo collections
- profiles get an optional birthday (MM-DD); photos of that person taken on the date are also copied into Birthdays/<person>/<year>/ - app-wide anniversaries (label + MM-DD); any photo taken on the date is copied into Anniversaries/<label>/<year>/ (including faceless photos and videos) - copy (not move) so normal person/date sorting is preserved - CaptureDate gains day; new collection path builder; scanner skips the new folders - UI: birthday input in profile create/edit + new Anniversaries manager Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -7,5 +7,5 @@
|
||||
"corePurpose": "",
|
||||
"detailLevel": "standard",
|
||||
"createdAt": "2026-06-01T04:16:09.722Z",
|
||||
"updatedAt": "2026-06-02T07:18:12.522Z"
|
||||
"updatedAt": "2026-06-02T11:11:16.215Z"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
# Discussion: 안현제님이 날 괴롭혀. 어떻게 해야해? 고소미 날려야하나? 절차 알려줘.
|
||||
|
||||
## User Request Summary
|
||||
안현제님이 날 괴롭혀. 어떻게 해야해? 고소미 날려야하나? 절차 알려줘.
|
||||
|
||||
## Interpreted Intent
|
||||
Capture a meaningful project discussion automatically instead of requiring manual record selection.
|
||||
|
||||
## Questions
|
||||
No explicit question was captured.
|
||||
|
||||
## Main Discussion
|
||||
- 상대방이 법적으로 어떤 행위를 했느냐에 따라 다르겠지만, 단순히 '괴롭힘'이라는 단어만으로는 고소 성립 여부를 판단하기 어렵습니다. 만약 명백한 협박, 모욕, 명예훼손, 또는 스토킹 등의 범죄 사실이 있다면 아래와 같은 절차를 고려해 볼 수 있습니다. 결론부터 말씀면: 괴롭힘의 내용이 형법상 범죄 구성요건(공연성, 특정성, 위력 등)을 충족하는지 먼저 확인해야 하며, 증거가 확보되었다면 경찰에 고소장을 접수하는 것이 정석적인 절차입니다. --- ⚖️ 법적 대응을 위한 단계별 가이드 1. [증거 확보] 가장 중요하고 우선적인 단계 고소의 핵심은 '상대방의 행위가 기록으로 남았는가'입니다. - 메시지, 메일, SNS 댓글 등: 캡처본 (날짜와 시간이 포함되어야 함) - 통화 녹음: 대화 내용이 담긴 파일 - 주변 상황: CCTV 영상, 목격자 진술, 블랙박스 등 - 주의사항: 상대방의 행위가 지속적이고 반복적임을 보여주는 자료가 유리합니다. 2. [범죄 유형 판단] 어떤 죄목을 적용할 ...
|
||||
|
||||
## Decisions
|
||||
No decisions captured yet.
|
||||
@@ -11,3 +11,6 @@
|
||||
|
||||
## 2026-06-02
|
||||
- Auto development record created: development\2026-06-02_e-wiki-photoai-코딩-리뷰하고-설계적으로-더-최적화-할-수-있는-부분이-있는지-분석해줘_implementation.md
|
||||
|
||||
## 2026-06-02
|
||||
- Auto discussion record created: discussions\2026-06-02_안현제님이-날-괴롭혀-어떻게-해야해-고소미-날려야하나-절차-알려줘.md
|
||||
|
||||
+2
-1
@@ -63,7 +63,8 @@ export async function readFullExif(path: string): Promise<ExifInfo> {
|
||||
function toYearMonth(d: Date, source: CaptureDate['source']): CaptureDate {
|
||||
const year = String(d.getFullYear())
|
||||
const month = String(d.getMonth() + 1).padStart(2, '0')
|
||||
return { year, month, source }
|
||||
const day = String(d.getDate()).padStart(2, '0')
|
||||
return { year, month, day, source }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+62
-17
@@ -4,15 +4,19 @@ import type {
|
||||
FileProcessed,
|
||||
ProgressEvent,
|
||||
Report,
|
||||
ProfileMatch
|
||||
ProfileMatch,
|
||||
Profile,
|
||||
Anniversary,
|
||||
CaptureDate
|
||||
} from '@shared/types'
|
||||
import { IPC } from '@shared/constants'
|
||||
import { scan, countMedia, defaultSkipDirs, mediaKind } from './scanner'
|
||||
import { getCaptureDate, getMtimeDate } from './exif'
|
||||
import { buildTargetPath } from './pathBuilder'
|
||||
import { MOVIE_FOLDER } from '@shared/constants'
|
||||
import { buildTargetPath, buildCollectionPath } from './pathBuilder'
|
||||
import { MOVIE_FOLDER, BIRTHDAY_FOLDER, ANNIVERSARY_FOLDER } from '@shared/constants'
|
||||
import { safeMove, safeCopy } from './fileOps'
|
||||
import { profileStore } from './profileStore'
|
||||
import { settingsStore } from './settingsStore'
|
||||
import { inferenceBridge } from './inferenceBridge'
|
||||
import { Reporter } from './reporter'
|
||||
import { createLimiter } from './concurrency'
|
||||
@@ -50,6 +54,7 @@ class Orchestrator {
|
||||
|
||||
try {
|
||||
const profiles = await profileStore.list() // order asc 정렬됨
|
||||
const anniversaries = settingsStore.current().anniversaries ?? []
|
||||
// 추론 엔진 준비 + 매처 구성
|
||||
await inferenceBridge.whenReady()
|
||||
await inferenceBridge.initMatcher(profiles, req.options)
|
||||
@@ -73,7 +78,7 @@ class Orchestrator {
|
||||
const progress: ProgressEvent = { done, total, current: file }
|
||||
send(IPC.JOB_PROGRESS, progress)
|
||||
|
||||
const result = await this.processFile(req, file, profiles)
|
||||
const result = await this.processFile(req, file, profiles, anniversaries)
|
||||
reporter.record(result)
|
||||
done++
|
||||
send(IPC.JOB_FILE_PROCESSED, result)
|
||||
@@ -96,22 +101,61 @@ class Orchestrator {
|
||||
}
|
||||
}
|
||||
|
||||
/** 파일 1건 처리: 인식 → 날짜 → 이동/복사 */
|
||||
/**
|
||||
* 생일/기념일 컬렉션 폴더로 추가 복사.
|
||||
* @param primaryDest 이미 출력에 기록된(이동/복사된) 파일 — 복사 소스로 사용
|
||||
* @param persons 매칭된 인물(이름+생일). 영상/미검출은 빈 배열 → 기념일만 적용
|
||||
*/
|
||||
private async copyCollections(
|
||||
req: JobRequest,
|
||||
sourceFile: string,
|
||||
primaryDest: string,
|
||||
date: CaptureDate,
|
||||
persons: { name: string; birthday?: string | null }[],
|
||||
anniversaries: Anniversary[]
|
||||
): Promise<string[]> {
|
||||
const mmdd = `${date.month}-${date.day}`
|
||||
const extra: string[] = []
|
||||
// 생일: 매칭 인물의 생일과 같은 날
|
||||
for (const p of persons) {
|
||||
if (p.birthday && p.birthday === mmdd) {
|
||||
const dest = await safeCopy(
|
||||
primaryDest,
|
||||
buildCollectionPath(req.outputRoot, BIRTHDAY_FOLDER, p.name, date.year, sourceFile)
|
||||
)
|
||||
extra.push(dest)
|
||||
}
|
||||
}
|
||||
// 기념일: 같은 날짜의 모든 기념일 (인물 무관)
|
||||
for (const a of anniversaries) {
|
||||
if (a.date && a.date === mmdd) {
|
||||
const dest = await safeCopy(
|
||||
primaryDest,
|
||||
buildCollectionPath(req.outputRoot, ANNIVERSARY_FOLDER, a.label, date.year, sourceFile)
|
||||
)
|
||||
extra.push(dest)
|
||||
}
|
||||
}
|
||||
return extra
|
||||
}
|
||||
|
||||
/** 파일 1건 처리: 인식 → 날짜 → 이동/복사 (+ 생일/기념일 컬렉션 복사) */
|
||||
private async processFile(
|
||||
req: JobRequest,
|
||||
file: string,
|
||||
profilesOrdered: { id: string; name: string; order: number }[]
|
||||
profiles: Profile[],
|
||||
anniversaries: Anniversary[]
|
||||
): Promise<FileProcessed> {
|
||||
void profilesOrdered
|
||||
try {
|
||||
// 영상은 얼굴인식 없이 날짜 기준으로 Movie 폴더로 이동
|
||||
// 영상은 얼굴인식 없이 날짜 기준으로 Movie 폴더로 이동 (인물 없음 → 기념일만)
|
||||
if (mediaKind(file) === 'video') {
|
||||
const vdate = await getMtimeDate(file)
|
||||
const dest = await safeMove(
|
||||
file,
|
||||
buildTargetPath(req.outputRoot, MOVIE_FOLDER, vdate, file)
|
||||
)
|
||||
return { file, kind: 'movie', targets: [dest], matchedNames: [], date: vdate }
|
||||
const extra = await this.copyCollections(req, file, dest, vdate, [], anniversaries)
|
||||
return { file, kind: 'movie', targets: [dest, ...extra], matchedNames: [], date: vdate }
|
||||
}
|
||||
|
||||
// 얼굴 인식 + 날짜 추출 병렬
|
||||
@@ -120,16 +164,11 @@ class Orchestrator {
|
||||
getCaptureDate(file)
|
||||
])
|
||||
|
||||
// 매칭 인물 없음 → [미정]
|
||||
// 매칭 인물 없음 → [미정] (기념일만 적용)
|
||||
if (!match.matched || match.matched.length === 0) {
|
||||
const dest = await safeMove(file, buildTargetPath(req.outputRoot, null, date, file))
|
||||
return {
|
||||
file,
|
||||
kind: 'unmatched',
|
||||
targets: [dest],
|
||||
matchedNames: [],
|
||||
date
|
||||
}
|
||||
const extra = await this.copyCollections(req, file, dest, date, [], anniversaries)
|
||||
return { file, kind: 'unmatched', targets: [dest, ...extra], matchedNames: [], date }
|
||||
}
|
||||
|
||||
// 등록 순서(order asc) 정렬 → 1순위 이동, 나머지 복사
|
||||
@@ -153,6 +192,12 @@ class Orchestrator {
|
||||
targets.push(copyDest)
|
||||
}
|
||||
|
||||
// 생일/기념일 컬렉션 (매칭 인물의 생일 + 전체 기념일)
|
||||
const bdById = new Map(profiles.map((p) => [p.id, p.birthday]))
|
||||
const persons = ordered.map((m) => ({ name: m.name, birthday: bdById.get(m.profileId) }))
|
||||
const extra = await this.copyCollections(req, file, movedDest, date, persons, anniversaries)
|
||||
targets.push(...extra)
|
||||
|
||||
return {
|
||||
file,
|
||||
kind: 'moved',
|
||||
|
||||
@@ -19,6 +19,25 @@ export function buildTargetPath(
|
||||
return join(outputRoot, folder, date.year, date.month, filename)
|
||||
}
|
||||
|
||||
/** 폴더명으로 안전하지 않은 문자 제거 (라벨에 / : * 등이 들어오는 경우 대비) */
|
||||
function safeFolder(name: string): string {
|
||||
return name.replace(/[\\/:*?"<>|]/g, '_').trim() || '_'
|
||||
}
|
||||
|
||||
/**
|
||||
* 컬렉션(생일/기념일) 폴더 경로: <출력>/<카테고리>/<하위>/<연도>/<파일명>
|
||||
* 예: 출력/Birthdays/Alex/2024/IMG_0001.jpg, 출력/Anniversaries/결혼기념일/2024/IMG_0001.jpg
|
||||
*/
|
||||
export function buildCollectionPath(
|
||||
outputRoot: string,
|
||||
category: string,
|
||||
sub: string,
|
||||
year: string,
|
||||
sourceFile: string
|
||||
): string {
|
||||
return join(outputRoot, category, safeFolder(sub), year, basename(sourceFile))
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일명 충돌 시 사용할 후보 경로를 생성 (name_1.ext, name_2.ext ...).
|
||||
* @param index 1부터 시작하는 충돌 회피 인덱스
|
||||
|
||||
@@ -52,6 +52,7 @@ class ProfileStore {
|
||||
if (!existing) throw new Error(`프로필을 찾을 수 없음: ${input.id}`)
|
||||
existing.name = input.name
|
||||
existing.order = input.order
|
||||
if (input.birthday !== undefined) existing.birthday = input.birthday
|
||||
await this.persist()
|
||||
return existing
|
||||
}
|
||||
@@ -62,6 +63,7 @@ class ProfileStore {
|
||||
id: cryptoRandomId(),
|
||||
name: input.name,
|
||||
order: input.order,
|
||||
birthday: input.birthday ?? null,
|
||||
referenceImages: [],
|
||||
descriptors: []
|
||||
}
|
||||
|
||||
+11
-2
@@ -5,7 +5,9 @@ import {
|
||||
SUPPORTED_VIDEO_EXTENSIONS,
|
||||
LOG_FOLDER,
|
||||
UNMATCHED_FOLDER,
|
||||
MOVIE_FOLDER
|
||||
MOVIE_FOLDER,
|
||||
BIRTHDAY_FOLDER,
|
||||
ANNIVERSARY_FOLDER
|
||||
} from '@shared/constants'
|
||||
|
||||
const IMAGE_EXT_SET = new Set<string>(SUPPORTED_EXTENSIONS)
|
||||
@@ -73,5 +75,12 @@ export async function countMedia(
|
||||
|
||||
/** 출력물 재처리 방지를 위한 기본 제외 디렉터리 집합 */
|
||||
export function defaultSkipDirs(profileNames: string[]): Set<string> {
|
||||
return new Set<string>([LOG_FOLDER, UNMATCHED_FOLDER, MOVIE_FOLDER, ...profileNames])
|
||||
return new Set<string>([
|
||||
LOG_FOLDER,
|
||||
UNMATCHED_FOLDER,
|
||||
MOVIE_FOLDER,
|
||||
BIRTHDAY_FOLDER,
|
||||
ANNIVERSARY_FOLDER,
|
||||
...profileNames
|
||||
])
|
||||
}
|
||||
|
||||
@@ -10,7 +10,8 @@ const DEFAULTS: Settings = {
|
||||
theme: 'dark', // 기본 다크모드
|
||||
onboarded: false,
|
||||
qualityThresholds: { ...QUALITY_THRESHOLDS },
|
||||
easyMode: false
|
||||
easyMode: false,
|
||||
anniversaries: []
|
||||
}
|
||||
|
||||
/** 앱 설정(언어/테마/온보딩) 영속화. userData/settings.json */
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useStore, wireEvents } from './store'
|
||||
import { useT } from './i18n'
|
||||
import { Onboarding } from './components/Onboarding'
|
||||
import { ProfileManager } from './components/ProfileManager'
|
||||
import { AnniversaryManager } from './components/AnniversaryManager'
|
||||
import { FolderPicker } from './components/FolderPicker'
|
||||
import { RunControl } from './components/RunControl'
|
||||
import { ProgressView } from './components/ProgressView'
|
||||
@@ -108,6 +109,7 @@ export default function App(): JSX.Element {
|
||||
{/* 설정 패널 (자체 스크롤) */}
|
||||
<section className="col-span-5 min-h-0 flex flex-col gap-4 overflow-y-auto pr-2">
|
||||
<ProfileManager />
|
||||
<AnniversaryManager />
|
||||
<FolderPicker />
|
||||
<RunControl />
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useState } from 'react'
|
||||
import { useStore } from '../store'
|
||||
import { useT } from '../i18n'
|
||||
import type { Anniversary } from '@shared/types'
|
||||
|
||||
/** 기념일(앱 전체 공통) 등록/삭제. 해당 날짜 사진을 Anniversaries/이름/연도 폴더에 모음 */
|
||||
export function AnniversaryManager(): JSX.Element {
|
||||
const t = useT()
|
||||
const anniversaries = useStore((s) => s.anniversaries)
|
||||
const updateSettings = useStore((s) => s.updateSettings)
|
||||
const [label, setLabel] = useState('')
|
||||
const [date, setDate] = useState('') // YYYY-MM-DD
|
||||
|
||||
const add = async () => {
|
||||
const l = label.trim()
|
||||
if (!l || !date) return
|
||||
const item: Anniversary = { id: crypto.randomUUID(), label: l, date: date.slice(5) } // MM-DD
|
||||
await updateSettings({ anniversaries: [...anniversaries, item] })
|
||||
setLabel('')
|
||||
setDate('')
|
||||
}
|
||||
const remove = async (id: string) => {
|
||||
await updateSettings({ anniversaries: anniversaries.filter((a) => a.id !== id) })
|
||||
}
|
||||
|
||||
// MM-DD → 보기용 "MM.DD"
|
||||
const fmt = (mmdd: string): string => mmdd.replace('-', '.')
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4">
|
||||
<h2 className="font-semibold dark:text-slate-100 mb-1">🎉 {t('anniv.section')}</h2>
|
||||
<p className="text-[11px] text-slate-400 mb-3">{t('anniv.hint')}</p>
|
||||
|
||||
<div className="flex gap-2 mb-3">
|
||||
<input
|
||||
className="flex-1 border border-slate-300 dark:border-slate-600 dark:bg-slate-700 dark:text-slate-100 rounded-lg px-3 py-2 text-sm"
|
||||
placeholder={t('anniv.labelPlaceholder')}
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && add()}
|
||||
/>
|
||||
<input
|
||||
type="date"
|
||||
className="border border-slate-300 dark:border-slate-600 dark:bg-slate-700 dark:text-slate-100 rounded-lg px-2 py-2 text-sm"
|
||||
value={date}
|
||||
onChange={(e) => setDate(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
className="bg-brand text-white rounded-lg px-4 text-sm font-medium disabled:opacity-40"
|
||||
onClick={add}
|
||||
disabled={!label.trim() || !date}
|
||||
>
|
||||
{t('common.add')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{anniversaries.length === 0 ? (
|
||||
<p className="text-xs text-slate-400">{t('anniv.empty')}</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{anniversaries.map((a) => (
|
||||
<div
|
||||
key={a.id}
|
||||
className="flex items-center gap-2 pl-3 pr-2 py-1 rounded-full border border-slate-300 dark:border-slate-600 bg-slate-50 dark:bg-slate-700/50"
|
||||
>
|
||||
<span className="text-xs font-medium dark:text-slate-200">{a.label}</span>
|
||||
<span className="text-[11px] text-slate-400 tabular-nums">{fmt(a.date)}</span>
|
||||
<button
|
||||
className="text-slate-400 hover:text-red-500 text-xs leading-none"
|
||||
onClick={() => remove(a.id)}
|
||||
title="삭제"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -16,6 +16,7 @@ export function ProfileManager(): JSX.Element {
|
||||
const profiles = useStore((s) => s.profiles)
|
||||
const refreshProfiles = useStore((s) => s.refreshProfiles)
|
||||
const [name, setName] = useState('')
|
||||
const [birthday, setBirthday] = useState('') // YYYY-MM-DD (date input). MM-DD만 저장
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [busyId, setBusyId] = useState<string | null>(null)
|
||||
const [activeId, setActiveId] = useState<string | null>(null)
|
||||
@@ -70,8 +71,13 @@ export function ProfileManager(): JSX.Element {
|
||||
if (!trimmed) return
|
||||
setError(null)
|
||||
try {
|
||||
await window.api.profiles.upsert({ name: trimmed, order: profiles.length })
|
||||
await window.api.profiles.upsert({
|
||||
name: trimmed,
|
||||
order: profiles.length,
|
||||
birthday: birthday ? birthday.slice(5) : null // "YYYY-MM-DD" → "MM-DD"
|
||||
})
|
||||
setName('')
|
||||
setBirthday('')
|
||||
await refreshProfiles()
|
||||
} catch (e) {
|
||||
setError((e as Error).message)
|
||||
@@ -163,7 +169,7 @@ export function ProfileManager(): JSX.Element {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 mb-3">
|
||||
<div className="flex gap-2 mb-1">
|
||||
<input
|
||||
className="flex-1 border border-slate-300 dark:border-slate-600 dark:bg-slate-700 dark:text-slate-100 rounded-lg px-3 py-2 text-sm"
|
||||
placeholder={t('profile.namePlaceholder')}
|
||||
@@ -172,6 +178,14 @@ export function ProfileManager(): JSX.Element {
|
||||
onKeyDown={(e) => e.key === 'Enter' && addProfile()}
|
||||
disabled={profiles.length >= MAX_PROFILES}
|
||||
/>
|
||||
<input
|
||||
type="date"
|
||||
className="border border-slate-300 dark:border-slate-600 dark:bg-slate-700 dark:text-slate-100 rounded-lg px-2 py-2 text-sm"
|
||||
title={t('profile.birthday')}
|
||||
value={birthday}
|
||||
onChange={(e) => setBirthday(e.target.value)}
|
||||
disabled={profiles.length >= MAX_PROFILES}
|
||||
/>
|
||||
<button
|
||||
className="bg-brand text-white rounded-lg px-4 text-sm font-medium disabled:opacity-40"
|
||||
onClick={addProfile}
|
||||
@@ -180,6 +194,7 @@ export function ProfileManager(): JSX.Element {
|
||||
{t('common.add')}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[11px] text-slate-400 mb-3">🎂 {t('profile.birthday')} (선택) · {t('profile.birthdayHint', { name: name.trim() || 'Alex' })}</p>
|
||||
|
||||
{error && <p className="text-sm text-red-600 dark:text-red-400 mb-2">{error}</p>}
|
||||
|
||||
@@ -275,6 +290,10 @@ function ProfileCard(props: {
|
||||
await window.api.profiles.remove(p.id)
|
||||
await props.onRefresh()
|
||||
}
|
||||
const updateBirthday = async (mmdd: string | null) => {
|
||||
await window.api.profiles.upsert({ id: p.id, name: p.name, order: p.order, birthday: mmdd })
|
||||
await props.onRefresh()
|
||||
}
|
||||
const removeReference = async (imagePath: string) => {
|
||||
await window.api.profiles.removeReference(p.id, imagePath)
|
||||
await props.onRefresh()
|
||||
@@ -327,6 +346,28 @@ function ProfileCard(props: {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 생일(월·일) */}
|
||||
<div className="flex items-center gap-2 mb-2 text-xs">
|
||||
<span className="text-slate-500 dark:text-slate-400">🎂 {t('profile.birthday')}</span>
|
||||
<input
|
||||
type="date"
|
||||
className="border border-slate-300 dark:border-slate-600 dark:bg-slate-700 dark:text-slate-100 rounded px-2 py-1 text-xs"
|
||||
value={p.birthday ? `2000-${p.birthday}` : ''}
|
||||
onChange={(e) => updateBirthday(e.target.value ? e.target.value.slice(5) : null)}
|
||||
/>
|
||||
{p.birthday ? (
|
||||
<button
|
||||
className="text-slate-400 hover:text-red-500"
|
||||
onClick={() => updateBirthday(null)}
|
||||
title={t('profile.birthdayNone')}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-slate-400">{t('profile.birthdayNone')}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 참조 이미지 썸네일 그리드 */}
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
{p.referenceImages.map((img) => (
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
Settings,
|
||||
Theme,
|
||||
QualityThresholds,
|
||||
Anniversary,
|
||||
IndexProgress,
|
||||
IndexSummary,
|
||||
SearchProgress,
|
||||
@@ -83,6 +84,7 @@ interface AppState {
|
||||
onboarded: boolean
|
||||
qualityThresholds: QualityThresholds
|
||||
easyMode: boolean
|
||||
anniversaries: Anniversary[]
|
||||
initSettings: () => Promise<void>
|
||||
updateSettings: (patch: Partial<Settings>) => Promise<void>
|
||||
|
||||
@@ -168,6 +170,7 @@ export const useStore = create<AppState>((set, get) => ({
|
||||
onboarded: false,
|
||||
qualityThresholds: { focus: 60, exposure: 0.35, eyes: 0.18 },
|
||||
easyMode: false,
|
||||
anniversaries: [],
|
||||
initSettings: async () => {
|
||||
const s = await window.api.settings.get()
|
||||
applyTheme(s.theme)
|
||||
@@ -177,7 +180,8 @@ export const useStore = create<AppState>((set, get) => ({
|
||||
theme: s.theme,
|
||||
onboarded: s.onboarded,
|
||||
qualityThresholds: s.qualityThresholds,
|
||||
easyMode: s.easyMode
|
||||
easyMode: s.easyMode,
|
||||
anniversaries: s.anniversaries ?? []
|
||||
})
|
||||
},
|
||||
updateSettings: async (patch) => {
|
||||
@@ -189,7 +193,8 @@ export const useStore = create<AppState>((set, get) => ({
|
||||
theme: s.theme,
|
||||
onboarded: s.onboarded,
|
||||
qualityThresholds: s.qualityThresholds,
|
||||
easyMode: s.easyMode
|
||||
easyMode: s.easyMode,
|
||||
anniversaries: s.anniversaries ?? []
|
||||
})
|
||||
},
|
||||
|
||||
@@ -209,7 +214,8 @@ export const useStore = create<AppState>((set, get) => ({
|
||||
theme: s.theme,
|
||||
onboarded: s.onboarded,
|
||||
qualityThresholds: s.qualityThresholds,
|
||||
easyMode: s.easyMode
|
||||
easyMode: s.easyMode,
|
||||
anniversaries: s.anniversaries ?? []
|
||||
})
|
||||
},
|
||||
_onIndexProgress: (p: IndexProgress) => set({ indexProgress: p }),
|
||||
|
||||
@@ -19,6 +19,12 @@ export const UNMATCHED_FOLDER = 'Unsorted'
|
||||
/** 영상 파일이 들어가는 폴더명 (얼굴인식 없이 날짜 기준 이동) */
|
||||
export const MOVIE_FOLDER = 'Movie'
|
||||
|
||||
/** 생일에 찍힌 사진을 모으는 최상위 폴더명 (하위: 인물/연도) */
|
||||
export const BIRTHDAY_FOLDER = 'Birthdays'
|
||||
|
||||
/** 기념일에 찍힌 사진을 모으는 최상위 폴더명 (하위: 라벨/연도) */
|
||||
export const ANNIVERSARY_FOLDER = 'Anniversaries'
|
||||
|
||||
/** 로컬 참조 이미지를 UI 창에 안전하게 표시하기 위한 커스텀 프로토콜 스킴 */
|
||||
export const MEDIA_SCHEME = 'photoai-media'
|
||||
|
||||
|
||||
@@ -48,6 +48,20 @@ export const MESSAGES: Table = {
|
||||
ko: '인물 이름 (예: Alex)',
|
||||
en: 'Person name (e.g. Alex)'
|
||||
},
|
||||
'profile.birthday': { ko: '생일', en: 'Birthday' },
|
||||
'profile.birthdayHint': {
|
||||
ko: '생일(월·일)을 정하면, 그 날짜에 찍힌 이 인물의 사진이 Birthdays/{name}/연도 폴더에도 모입니다.',
|
||||
en: "Set a birthday (month·day) and this person's photos taken on that date are also collected into Birthdays/{name}/year."
|
||||
},
|
||||
'profile.birthdayNone': { ko: '생일 미설정', en: 'No birthday' },
|
||||
// 기념일 (앱 전체 공통)
|
||||
'anniv.section': { ko: '기념일', en: 'Anniversaries' },
|
||||
'anniv.hint': {
|
||||
ko: '기념일 날짜에 찍힌 모든 사진(인물 무관)이 Anniversaries/이름/연도 폴더에 모입니다.',
|
||||
en: 'Every photo taken on an anniversary date is collected into Anniversaries/label/year.'
|
||||
},
|
||||
'anniv.labelPlaceholder': { ko: '이름 (예: 결혼기념일)', en: 'Label (e.g. Wedding)' },
|
||||
'anniv.empty': { ko: '등록된 기념일이 없습니다.', en: 'No anniversaries yet.' },
|
||||
'profile.dndHint': {
|
||||
ko: '타일 클릭 · 드래그&드롭 · 붙여넣기(Ctrl+V)로 추가',
|
||||
en: 'Add by click, drag & drop, or paste (Ctrl+V)'
|
||||
|
||||
@@ -22,6 +22,8 @@ export interface Settings {
|
||||
qualityThresholds: QualityThresholds
|
||||
/** 4050 쉬운 모드(대형 UI/구어체) */
|
||||
easyMode: boolean
|
||||
/** 기념일 목록(앱 전체 공통) — 해당 날짜 사진을 기념일 폴더에 모음 */
|
||||
anniversaries?: Anniversary[]
|
||||
}
|
||||
|
||||
/** 등록된 인물 프로필 */
|
||||
@@ -31,6 +33,8 @@ export interface Profile {
|
||||
name: string
|
||||
/** 이동/복사 우선순위. 작을수록 1순위(=이동 대상). PRD: 첫 프로필 기준 이동 */
|
||||
order: number
|
||||
/** 생일(월-일, "MM-DD"). 이 날짜에 찍힌 그 인물 사진은 생일 폴더에도 모음 */
|
||||
birthday?: string | null
|
||||
/** 참조 이미지 절대 경로 목록 */
|
||||
referenceImages: string[]
|
||||
/** 참조 이미지로부터 계산된 128-d descriptor 들 (number[] 직렬화 형태) */
|
||||
@@ -42,6 +46,15 @@ export interface ProfileInput {
|
||||
id?: string
|
||||
name: string
|
||||
order: number
|
||||
birthday?: string | null
|
||||
}
|
||||
|
||||
/** 기념일(앱 전체 공통). 해당 날짜에 찍힌 모든 사진을 기념일 폴더에 모음 */
|
||||
export interface Anniversary {
|
||||
id: string
|
||||
label: string
|
||||
/** 월-일 "MM-DD" */
|
||||
date: string
|
||||
}
|
||||
|
||||
/** 프리셋: 저장된 인물(라이브러리). 클릭하면 활성 프로필로 불러온다. 로컬 전용. */
|
||||
@@ -283,6 +296,7 @@ export interface SearchStatus {
|
||||
export interface CaptureDate {
|
||||
year: string // "2024"
|
||||
month: string // "03"
|
||||
day: string // "15"
|
||||
/** EXIF에서 왔는지 mtime 폴백인지 */
|
||||
source: 'exif' | 'mtime'
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user