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:
2026-06-02 20:11:29 +09:00
parent d73e11f0fd
commit 9b044449a0
16 changed files with 287 additions and 27 deletions
+1 -1
View File
@@ -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.
+3
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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부터 시작하는 충돌 회피 인덱스
+2
View File
@@ -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
View File
@@ -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
])
}
+2 -1
View File
@@ -10,7 +10,8 @@ const DEFAULTS: Settings = {
theme: 'dark', // 기본 다크모드
onboarded: false,
qualityThresholds: { ...QUALITY_THRESHOLDS },
easyMode: false
easyMode: false,
anniversaries: []
}
/** 앱 설정(언어/테마/온보딩) 영속화. userData/settings.json */
+2
View File
@@ -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>
)
}
+43 -2
View File
@@ -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) => (
+9 -3
View File
@@ -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 }),
+6
View File
@@ -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'
+14
View File
@@ -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)'
+14
View File
@@ -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'
}