diff --git a/docs/records/PhotoAI/chronicle.config.json b/docs/records/PhotoAI/chronicle.config.json index c7040b1..b567fec 100644 --- a/docs/records/PhotoAI/chronicle.config.json +++ b/docs/records/PhotoAI/chronicle.config.json @@ -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" } diff --git a/docs/records/PhotoAI/discussions/2026-06-02_안현제님이-날-괴롭혀-어떻게-해야해-고소미-날려야하나-절차-알려줘.md b/docs/records/PhotoAI/discussions/2026-06-02_안현제님이-날-괴롭혀-어떻게-해야해-고소미-날려야하나-절차-알려줘.md new file mode 100644 index 0000000..97cb352 --- /dev/null +++ b/docs/records/PhotoAI/discussions/2026-06-02_안현제님이-날-괴롭혀-어떻게-해야해-고소미-날려야하나-절차-알려줘.md @@ -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. diff --git a/docs/records/PhotoAI/timeline.md b/docs/records/PhotoAI/timeline.md index 2d2afff..347ba9e 100644 --- a/docs/records/PhotoAI/timeline.md +++ b/docs/records/PhotoAI/timeline.md @@ -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 diff --git a/src/main/exif.ts b/src/main/exif.ts index 5a5a239..13d134b 100644 --- a/src/main/exif.ts +++ b/src/main/exif.ts @@ -63,7 +63,8 @@ export async function readFullExif(path: string): Promise { 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 } } /** diff --git a/src/main/orchestrator.ts b/src/main/orchestrator.ts index b36ec5e..a42e6be 100644 --- a/src/main/orchestrator.ts +++ b/src/main/orchestrator.ts @@ -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 { + 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 { - 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', diff --git a/src/main/pathBuilder.ts b/src/main/pathBuilder.ts index 8ff23b5..b9b9741 100644 --- a/src/main/pathBuilder.ts +++ b/src/main/pathBuilder.ts @@ -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부터 시작하는 충돌 회피 인덱스 diff --git a/src/main/profileStore.ts b/src/main/profileStore.ts index add2210..123c9f6 100644 --- a/src/main/profileStore.ts +++ b/src/main/profileStore.ts @@ -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: [] } diff --git a/src/main/scanner.ts b/src/main/scanner.ts index f3962ec..ca0d6c7 100644 --- a/src/main/scanner.ts +++ b/src/main/scanner.ts @@ -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(SUPPORTED_EXTENSIONS) @@ -73,5 +75,12 @@ export async function countMedia( /** 출력물 재처리 방지를 위한 기본 제외 디렉터리 집합 */ export function defaultSkipDirs(profileNames: string[]): Set { - return new Set([LOG_FOLDER, UNMATCHED_FOLDER, MOVIE_FOLDER, ...profileNames]) + return new Set([ + LOG_FOLDER, + UNMATCHED_FOLDER, + MOVIE_FOLDER, + BIRTHDAY_FOLDER, + ANNIVERSARY_FOLDER, + ...profileNames + ]) } diff --git a/src/main/settingsStore.ts b/src/main/settingsStore.ts index eac92e3..92c0765 100644 --- a/src/main/settingsStore.ts +++ b/src/main/settingsStore.ts @@ -10,7 +10,8 @@ const DEFAULTS: Settings = { theme: 'dark', // 기본 다크모드 onboarded: false, qualityThresholds: { ...QUALITY_THRESHOLDS }, - easyMode: false + easyMode: false, + anniversaries: [] } /** 앱 설정(언어/테마/온보딩) 영속화. userData/settings.json */ diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 18b35e6..6334c71 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -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 { {/* 설정 패널 (자체 스크롤) */}
+
diff --git a/src/renderer/components/AnniversaryManager.tsx b/src/renderer/components/AnniversaryManager.tsx new file mode 100644 index 0000000..791fa9a --- /dev/null +++ b/src/renderer/components/AnniversaryManager.tsx @@ -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 ( +
+

🎉 {t('anniv.section')}

+

{t('anniv.hint')}

+ +
+ setLabel(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && add()} + /> + setDate(e.target.value)} + /> + +
+ + {anniversaries.length === 0 ? ( +

{t('anniv.empty')}

+ ) : ( +
+ {anniversaries.map((a) => ( +
+ {a.label} + {fmt(a.date)} + +
+ ))} +
+ )} +
+ ) +} diff --git a/src/renderer/components/ProfileManager.tsx b/src/renderer/components/ProfileManager.tsx index 7e040b9..28a8632 100644 --- a/src/renderer/components/ProfileManager.tsx +++ b/src/renderer/components/ProfileManager.tsx @@ -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(null) const [busyId, setBusyId] = useState(null) const [activeId, setActiveId] = useState(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 { -
+
e.key === 'Enter' && addProfile()} disabled={profiles.length >= MAX_PROFILES} /> + setBirthday(e.target.value)} + disabled={profiles.length >= MAX_PROFILES} + />
+

🎂 {t('profile.birthday')} (선택) · {t('profile.birthdayHint', { name: name.trim() || 'Alex' })}

{error &&

{error}

} @@ -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: {
+ {/* 생일(월·일) */} +
+ 🎂 {t('profile.birthday')} + updateBirthday(e.target.value ? e.target.value.slice(5) : null)} + /> + {p.birthday ? ( + + ) : ( + {t('profile.birthdayNone')} + )} +
+ {/* 참조 이미지 썸네일 그리드 */}
{p.referenceImages.map((img) => ( diff --git a/src/renderer/store.ts b/src/renderer/store.ts index 055e9c3..4c44ca7 100644 --- a/src/renderer/store.ts +++ b/src/renderer/store.ts @@ -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 updateSettings: (patch: Partial) => Promise @@ -168,6 +170,7 @@ export const useStore = create((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((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((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((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 }), diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 19e2de0..c5df7b1 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -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' diff --git a/src/shared/i18n.ts b/src/shared/i18n.ts index d9581d7..6664d3b 100644 --- a/src/shared/i18n.ts +++ b/src/shared/i18n.ts @@ -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)' diff --git a/src/shared/types.ts b/src/shared/types.ts index a0f9e4e..c5bc55b 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -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' }