feat(l10n): localize export file names per language

- 내보내기 파일명을 언어별로 현지화 — 수신 환경(PC·클라우드·메일)이 한글 파일명을 지원하지 않을 수 있음 (사용자 결정). ko는 기존 그대로("하루다님-기록-…"), en/ja는 라틴 접두 "HaruDanim-" + 그 언어의 종류 단어 (History/記録, Diary/日記, Stats/統計, Actions/アクション 등)
- 대상 전부: 기록·통계 리포트 PNG(ExportSnapshot.fileName + 슬러그 3종), 일기 PDF·스티치 PNG(슬러그 3종 포함), CSV 5종(행동·목표·다짐은 기존 키 재사용, 시간기록·횟수기록 신규 키). CSV 헤더는 데이터로 보고 한국어 유지
- 카탈로그 신규 11키 en/ja 완비(751키 missing/stale 0), CLAUDE.md §7에 규칙 기재
- 검증(시뮬레이터 실파일명): en CSV 5종 "HaruDanim-Actions/TimeRecords/CountRecords/Goals/Quest.csv", en 리포트 "HaruDanim-History-2026-07-16.png", iPad en 일기 "HaruDanim-Diary-2026-07-15~2026-07-16.pdf"(기간 슬러그), ko 회귀 "하루다님-행동.csv" 등 5종 그대로. Debug·Store 빌드 성공

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7
This commit is contained in:
songyc macbook 2026-07-16 10:22:01 +09:00
parent 4e07a90a1b
commit b17a199b53
5 changed files with 210 additions and 15 deletions

View File

@ -225,6 +225,7 @@ xcrun xcstringstool sync Widgets/Localizable.xcstrings --stringsdata "${widget_f
- `ExportSnapshot`: SwiftData 비의존 값 타입 (title/periodLabel/filterNote/hero/sections). 섹션 종류: timetable / records / bars / lines / table
- `ExportBuilder.history(...)` / `.stats(...)`: 화면과 동일한 집계 규칙으로 스냅숏 생성. `trimHours: false`면 기록이 없어도 24시간 전체 그리드(일기용)
- `injectingCalendarEvents(_:)`: 하루 타임테이블에 캘린더 블록 주입. `replacingTimetableSections(with:)`: 일기 필터용 — 타임테이블 섹션만 교체하고 나머지는 유지
- **내보내기 파일명은 언어별 현지화** — ko "하루다님-기록-…" / en "HaruDanim-History-…" / ja "HaruDanim-記録-…" (수신 환경이 한글을 지원하지 않을 수 있어 접두는 en/ja에서 라틴 통일). 리포트 PNG·일기 PDF/PNG·CSV 5종 모두 적용, 패턴은 카탈로그 포맷 키("하루다님-%@.png" 등). CSV 헤더는 데이터로 보고 한국어 유지
- `ExportPosterView`: 고정폭 430pt, ImageRenderer scale 3 → 1290px PNG. 라이트/다크는 `.environment(\.colorScheme)` 강제. `ExportSectionView`/`ExportHeroRow`는 일기 요약 화면과 공용 — **화면과 내보내기 결과가 항상 일치**
- 일기 인쇄용: `DiaryPrintSummaryView`(768pt) + `DiaryPrintNotePageView`(768×1024, 라이트 고정)

View File

@ -2105,6 +2105,38 @@
}
}
},
"기록-%@": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "History-%@"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "記録-%@"
}
}
}
},
"기록-주간-%@": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "History-Weekly-%@"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "記録-週間-%@"
}
}
}
},
"기록·통계 탭": {
"localizations": {
"en": {
@ -6254,6 +6286,23 @@
}
}
},
"시간기록": {
"comment": "CSV 파일명용 (공백 없이)",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "TimeRecords"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "時間記録"
}
}
}
},
"시간을 측정 중이면 다이나믹 아일랜드와 잠금화면에 진행 시간이 표시돼요. 여러 개를 동시에 측정하면 설정의 '대표 시간 기준'(먼저/나중에 시작한 행동)에 따라 하나가 대표로 보이고 나머지는 +N으로 표시돼요.": {
"localizations": {
"en": {
@ -8192,6 +8241,44 @@
}
}
},
"일기-%@": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Diary-%@"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "日記-%@"
}
}
}
},
"일기-%@~%@": {
"localizations": {
"ko": {
"stringUnit": {
"state": "new",
"value": "일기-%1$@~%2$@"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Diary-%@~%@"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "日記-%@~%@"
}
}
}
},
"일기는 프리미엄 기능이에요": {
"localizations": {
"en": {
@ -9969,6 +10056,28 @@
}
}
},
"통계-%@-%@": {
"localizations": {
"ko": {
"stringUnit": {
"state": "new",
"value": "통계-%1$@-%2$@"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Stats-%@-%@"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "統計-%@-%@"
}
}
}
},
"특정 기간": {
"localizations": {
"en": {
@ -10786,6 +10895,70 @@
}
}
},
"하루다님-%@.csv": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "HaruDanim-%@.csv"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "HaruDanim-%@.csv"
}
}
}
},
"하루다님-%@.pdf": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "HaruDanim-%@.pdf"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "HaruDanim-%@.pdf"
}
}
}
},
"하루다님-%@.png": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "HaruDanim-%@.png"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "HaruDanim-%@.png"
}
}
}
},
"하루다님-일기-%@.png": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "HaruDanim-Diary-%@.png"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "HaruDanim-日記-%@.png"
}
}
}
},
"하루의 기록·통계와 함께 애플 펜슬로 쓰는\n나만의 다이어리를 만들 수 있어요.": {
"localizations": {
"en": {
@ -11982,6 +12155,23 @@
}
}
}
},
"횟수기록": {
"comment": "CSV 파일명용 (공백 없이)",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "CountRecords"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "回数記録"
}
}
}
}
},
"version": "1.0"

View File

@ -93,7 +93,8 @@ struct DataExportView: View {
isRendering = true
Task { @MainActor in
var urls: [URL?] = []
urls.append(write("행동", header: ["id", "이름", "추적방식", "꼬리표", "즐겨찾기", "만든날"],
// ( )
urls.append(write(String(localized: "행동"), header: ["id", "이름", "추적방식", "꼬리표", "즐겨찾기", "만든날"],
rows: actions.map { action in
[action.uuid.uuidString, action.name,
action.trackingType == .time ? "time" : "count",
@ -101,7 +102,7 @@ struct DataExportView: View {
action.isFavorite ? "true" : "false",
iso(action.createdAt)]
}))
urls.append(write("시간기록", header: ["행동id", "행동", "시작", "종료", "", "메모"],
urls.append(write(String(localized: "시간기록", comment: "CSV 파일명용 (공백 없이)"), header: ["행동id", "행동", "시작", "종료", "", "메모"],
rows: sessions.compactMap { session in
guard let action = session.action else { return nil }
return [action.uuid.uuidString, action.name,
@ -110,19 +111,19 @@ struct DataExportView: View {
String(Int(session.duration())),
session.note]
}))
urls.append(write("횟수기록", header: ["행동id", "행동", "시각", "수량", "메모"],
urls.append(write(String(localized: "횟수기록", comment: "CSV 파일명용 (공백 없이)"), header: ["행동id", "행동", "시각", "수량", "메모"],
rows: entries.compactMap { entry in
guard let action = entry.action else { return nil }
return [action.uuid.uuidString, action.name,
iso(entry.timestamp), String(entry.amount), entry.note]
}))
urls.append(write("목표", header: ["id", "내용", "시작일", "종료일", "상태", "달성기준%"],
urls.append(write(String(localized: "목표"), header: ["id", "내용", "시작일", "종료일", "상태", "달성기준%"],
rows: goals.map { goal in
[goal.uuid.uuidString, goal.title, iso(goal.startDate),
goal.endDate.map(iso) ?? "", goal.statusRaw,
String(goal.achieveThresholdPercent)]
}))
urls.append(write("다짐", header: ["id", "목표", "대상", "주기", "목표량", "방향"],
urls.append(write(String(localized: "다짐"), header: ["id", "목표", "대상", "주기", "목표량", "방향"],
rows: goals.flatMap(\.sortedQuests).map { quest in
[quest.uuid.uuidString, quest.goal?.title ?? "",
quest.targetName, quest.scheduleLabel,
@ -162,7 +163,7 @@ struct DataExportView: View {
// UTF-8 BOM
let content = "\u{FEFF}" + lines.joined(separator: "\n")
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("하루다님-\(name).csv")
.appendingPathComponent(String(localized: "하루다님-\(name).csv"))
do {
try content.write(to: url, atomically: true, encoding: .utf8)
return url

View File

@ -220,9 +220,10 @@ struct DiaryExportSheet: View {
private var fileSlug: String {
let entries = targetEntries
guard let first = entries.first else { return "일기" }
if entries.count == 1 { return "일기-\(slugDate(first.dayKey))" }
return "일기-\(slugDate(first.dayKey))~\(slugDate(entries.last!.dayKey))"
// (ExportSnapshot.fileName )
guard let first = entries.first else { return String(localized: "일기") }
if entries.count == 1 { return String(localized: "일기-\(slugDate(first.dayKey))") }
return String(localized: "일기-\(slugDate(first.dayKey))~\(slugDate(entries.last!.dayKey))")
}
/// ( ) @Query
@ -306,7 +307,7 @@ struct DiaryExportSheet: View {
@MainActor
private func renderPDF(_ entries: [DiaryEntry]) -> URL? {
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("하루다님-\(fileSlug).pdf")
.appendingPathComponent(String(localized: "하루다님-\(fileSlug).pdf"))
let bounds = CGRect(origin: .zero, size: DiaryPageMetrics.size)
let renderer = UIGraphicsPDFRenderer(bounds: bounds)
do {
@ -353,7 +354,7 @@ struct DiaryExportSheet: View {
}
guard let data = stitched.pngData() else { return nil }
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("하루다님-일기-\(slugDate(entry.dayKey)).png")
.appendingPathComponent(String(localized: "하루다님-일기-\(slugDate(entry.dayKey)).png"))
try? data.write(to: url, options: .atomic)
return url
}

View File

@ -32,7 +32,9 @@ struct ExportSnapshot {
/// (: "-2026-07-13")
var fileSlug: String
var fileName: String { "하루다님-\(fileSlug).png" }
// (PC·)
// en/ja "HaruDanim-" (fileSlug )
var fileName: String { String(localized: "하루다님-\(fileSlug).png") }
/// ( · )
func injectingCalendarEvents(_ events: [ExportTimetableData.Block]) -> ExportSnapshot {
@ -272,7 +274,7 @@ enum ExportBuilder {
filterNote: note,
hero: recordHero(segments: segments, counts: counts),
sections: sections,
fileSlug: "기록-주간-\(slugDate(keys.first ?? dayKey))"
fileSlug: String(localized: "기록-주간-\(slugDate(keys.first ?? dayKey))")
)
}
@ -326,7 +328,7 @@ enum ExportBuilder {
filterNote: note,
hero: recordHero(segments: segments, counts: counts),
sections: sections,
fileSlug: "기록-\(slugDate(dayKey))"
fileSlug: String(localized: "기록-\(slugDate(dayKey))")
)
}
@ -761,7 +763,7 @@ enum ExportBuilder {
filterNote: note,
hero: hero,
sections: sections,
fileSlug: "통계-\(span.rawValue)-\(slugDate(anchorDayKey))"
fileSlug: String(localized: "통계-\(span.rawValue)-\(slugDate(anchorDayKey))")
)
}
}