feat(export): CSV 내보내기 구간 지정 — 기록만 걸러내고 구조는 전체 유지, 코어를 CSVExport로 추출
- 기존 동작 검증: 전량 내보내기(무필터)였음을 확인 후 '전체(기본)/직접 지정' 세그먼트 추가 - 세션은 구간 겹침 포함(기록 탭 범위 문법), 구간 파일명 접미 -yyyyMMdd-yyyyMMdd - 행동·목표·다짐 목록은 항상 전체(가져오기 연결·백업 온전성) — footer로 안내 - CSV 생성 코어 CSVExport 신설: 데이터 정리·초기화(예정)의 삭제 전 백업이 재사용 - -dataExportRangeDays 검증 인자, 전체(2031·8044건)↔최근 7일(4·1건) 실파일 대조 확인 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NNxA172hNDRpDsJ2qztKPf
This commit is contained in:
parent
e7868f453c
commit
8c93da5ff3
131
myApp/HaruDanim/IOS/Core/CSVExport.swift
Normal file
131
myApp/HaruDanim/IOS/Core/CSVExport.swift
Normal file
@ -0,0 +1,131 @@
|
||||
//
|
||||
// CSVExport.swift
|
||||
// Haru_Danim
|
||||
//
|
||||
// 원시 데이터 CSV 생성 코어 (1.4에서 DataExportView에서 추출).
|
||||
// 설정 → 데이터 내보내기 화면과 '데이터 정리·초기화'(삭제 전 백업)가 공유한다.
|
||||
// 형식 규칙은 기존과 동일: ISO 8601 시각, UTF-8 BOM, 파일명·헤더 현지화(§7),
|
||||
// 값의 rawValue(time/count 등)는 데이터라 언어 무관 유지.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
enum CSVExport {
|
||||
/// 다섯 파일(행동·시간기록·횟수기록·목표·다짐)을 임시 디렉터리에 만들어 URL을 돌려준다.
|
||||
/// - range: nil = 전체. 지정하면 **기록(시간·횟수)만** 구간으로 거른다 —
|
||||
/// 행동·목표·다짐은 목록(구조)이라 항상 전체(가져오기 연결·백업 온전성 유지).
|
||||
/// 세션은 구간 겹침 포함(기록 탭 범위 조회와 같은 문법), 횟수는 시각 포함 여부.
|
||||
/// - fileSuffix: 파일명 뒤에 붙일 구분자(예: "-20260713-20260811"). 빈 값 = 없음
|
||||
static func makeFiles(
|
||||
actions: [Action],
|
||||
sessions: [TimeSession],
|
||||
entries: [CountEntry],
|
||||
goals: [Goal],
|
||||
range: Range<Date>? = nil,
|
||||
fileSuffix: String = ""
|
||||
) -> [URL] {
|
||||
let filteredSessions: [TimeSession]
|
||||
let filteredEntries: [CountEntry]
|
||||
if let range {
|
||||
let now = Date.now
|
||||
filteredSessions = sessions.filter {
|
||||
$0.startAt < range.upperBound && ($0.endAt ?? now) > range.lowerBound
|
||||
}
|
||||
filteredEntries = entries.filter { range.contains($0.timestamp) }
|
||||
} else {
|
||||
filteredSessions = sessions
|
||||
filteredEntries = entries
|
||||
}
|
||||
|
||||
var urls: [URL?] = []
|
||||
urls.append(write(String(localized: "행동") + fileSuffix, header: [
|
||||
"id", String(localized: "이름"), String(localized: "추적 방식"),
|
||||
String(localized: "꼬리표"), String(localized: "즐겨찾기"), String(localized: "만든날"),
|
||||
],
|
||||
rows: actions.map { action in
|
||||
[action.uuid.uuidString, action.name,
|
||||
action.trackingType == .time ? "time" : "count",
|
||||
action.sortedTags.map(\.name).joined(separator: "; "),
|
||||
action.isFavorite ? "true" : "false",
|
||||
iso(action.createdAt)]
|
||||
}))
|
||||
urls.append(write(String(localized: "시간기록", comment: "CSV 파일명용 (공백 없이)") + fileSuffix, header: [
|
||||
String(localized: "행동id"), String(localized: "행동"), String(localized: "시작"),
|
||||
String(localized: "종료"), String(localized: "초"), String(localized: "메모"),
|
||||
],
|
||||
rows: filteredSessions.compactMap { session in
|
||||
guard let action = session.action else { return nil }
|
||||
return [action.uuid.uuidString, action.name,
|
||||
iso(session.startAt),
|
||||
session.endAt.map(iso) ?? "",
|
||||
String(Int(session.duration())),
|
||||
session.note]
|
||||
}))
|
||||
urls.append(write(String(localized: "횟수기록", comment: "CSV 파일명용 (공백 없이)") + fileSuffix, header: [
|
||||
String(localized: "행동id"), String(localized: "행동"), String(localized: "시각"),
|
||||
String(localized: "수량"), String(localized: "메모"),
|
||||
],
|
||||
rows: filteredEntries.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(String(localized: "목표") + fileSuffix, header: [
|
||||
"id", String(localized: "내용"), String(localized: "시작일"),
|
||||
String(localized: "종료일"), String(localized: "상태"), String(localized: "달성기준%"),
|
||||
],
|
||||
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(String(localized: "다짐") + fileSuffix, header: [
|
||||
"id", String(localized: "목표"), String(localized: "대상"),
|
||||
String(localized: "주기"), String(localized: "목표량"), String(localized: "방향"),
|
||||
],
|
||||
rows: goals.flatMap(\.sortedQuests).map { quest in
|
||||
[quest.uuid.uuidString, quest.goal?.title ?? "",
|
||||
quest.targetName, quest.scheduleLabel,
|
||||
quest.measure == .time ? String(Int(quest.targetSeconds)) + "s" : String(quest.targetCount),
|
||||
quest.direction == .atLeast ? "atLeast" : "atMost"]
|
||||
}))
|
||||
return urls.compactMap(\.self)
|
||||
}
|
||||
|
||||
/// 구간 파일명 접미("-yyyyMMdd-yyyyMMdd") — 하루 키 기준, 언어 무관 고정 포맷
|
||||
static func fileSuffix(for range: Range<Date>, math: DayMath) -> String {
|
||||
let keys = math.dayKeys(in: range)
|
||||
guard let first = keys.first, let last = keys.last else { return "" }
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
formatter.dateFormat = "yyyyMMdd"
|
||||
return "-\(formatter.string(from: first))-\(formatter.string(from: last))"
|
||||
}
|
||||
|
||||
private static func iso(_ date: Date) -> String {
|
||||
date.formatted(.iso8601)
|
||||
}
|
||||
|
||||
/// CSV 필드 이스케이프 (쉼표·따옴표·줄바꿈 포함 시 따옴표로 감쌈)
|
||||
private static func escape(_ field: String) -> String {
|
||||
if field.contains(",") || field.contains("\"") || field.contains("\n") {
|
||||
return "\"" + field.replacingOccurrences(of: "\"", with: "\"\"") + "\""
|
||||
}
|
||||
return field
|
||||
}
|
||||
|
||||
private static func write(_ name: String, header: [String], rows: [[String]]) -> URL? {
|
||||
let lines = [header.map(escape).joined(separator: ",")]
|
||||
+ rows.map { $0.map(escape).joined(separator: ",") }
|
||||
// 엑셀이 UTF-8 한글을 바로 인식하도록 BOM을 붙인다
|
||||
let content = "\u{FEFF}" + lines.joined(separator: "\n")
|
||||
let url = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(String(localized: "하루다님-\(name).csv"))
|
||||
do {
|
||||
try content.write(to: url, atomically: true, encoding: .utf8)
|
||||
return url
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -6,6 +6,8 @@
|
||||
// 모든 기록을 CSV 파일로 만들어 공유한다 — 백업·이동·스프레드시트 분석용.
|
||||
// 리포트 이미지(ExportImageView)와 달리 가공 없는 원본 값을 담는다.
|
||||
// 무료 사용자도 사용 가능 (자기 데이터에 대한 접근권).
|
||||
// 1.4: 구간 지정 옵션 — 기록(시간·횟수)만 구간으로 거르고 행동·목표·다짐 목록은
|
||||
// 항상 전체를 담는다. CSV 생성 코어는 CSVExport(초기화 백업과 공유)로 추출.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
@ -17,15 +19,71 @@ struct DataExportView: View {
|
||||
@Query(sort: \CountEntry.timestamp) private var entries: [CountEntry]
|
||||
@Query(sort: [SortDescriptor(\Goal.sortOrder), SortDescriptor(\Goal.createdAt)]) private var goals: [Goal]
|
||||
|
||||
private enum RangeMode: String, CaseIterable, Identifiable {
|
||||
case all, custom
|
||||
var id: String { rawValue }
|
||||
var label: String {
|
||||
switch self {
|
||||
case .all: return String(localized: "전체")
|
||||
case .custom: return String(localized: "직접 지정")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@State private var rangeMode: RangeMode = .all
|
||||
@State private var startDate: Date = Calendar.current.date(byAdding: .day, value: -30, to: .now) ?? .now
|
||||
@State private var endDate: Date = .now
|
||||
@State private var isRendering = false
|
||||
@State private var resultURLs: [URL] = []
|
||||
|
||||
private var math: DayMath { DayMath() }
|
||||
|
||||
/// 선택한 달력일 구간 → 논리적 하루 경계 범위 (§4.1: 달력일은 startOfDay를 키로 직접 사용)
|
||||
private var selectedRange: Range<Date>? {
|
||||
guard rangeMode == .custom else { return nil }
|
||||
let startKey = math.calendar.startOfDay(for: min(startDate, endDate))
|
||||
let endKey = math.calendar.startOfDay(for: max(startDate, endDate))
|
||||
return math.dayRange(forKey: startKey).lowerBound..<math.dayRange(forKey: endKey).upperBound
|
||||
}
|
||||
|
||||
/// 목록 행 카운트가 실제로 내보낼 건수를 보여 주도록 구간 반영
|
||||
private var visibleSessionCount: Int {
|
||||
guard let range = selectedRange else { return sessions.count }
|
||||
let now = Date.now
|
||||
return sessions.count {
|
||||
$0.startAt < range.upperBound && ($0.endAt ?? now) > range.lowerBound
|
||||
}
|
||||
}
|
||||
|
||||
private var visibleEntryCount: Int {
|
||||
guard let range = selectedRange else { return entries.count }
|
||||
return entries.count { range.contains($0.timestamp) }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section {
|
||||
Picker("구간", selection: $rangeMode) {
|
||||
ForEach(RangeMode.allCases) { mode in
|
||||
Text(mode.label).tag(mode)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
if rangeMode == .custom {
|
||||
DatePicker("부터", selection: $startDate, in: ...endDate, displayedComponents: .date)
|
||||
DatePicker("까지", selection: $endDate, in: startDate..., displayedComponents: .date)
|
||||
}
|
||||
} header: {
|
||||
Text("내보낼 구간")
|
||||
} footer: {
|
||||
if rangeMode == .custom {
|
||||
Text("시간·횟수 기록만 구간으로 걸러져요. 행동·목표·다짐 목록은 항상 전체가 담겨요 (가져오기에서 기록을 행동과 연결하는 데 필요해요).")
|
||||
}
|
||||
}
|
||||
Section {
|
||||
row("figure.walk", String(localized: "행동"), String(localized: "\(actions.count)개"))
|
||||
row("timer", String(localized: "시간 기록"), String(localized: "\(sessions.count)건"))
|
||||
row("number", String(localized: "횟수 기록"), String(localized: "\(entries.count)건"))
|
||||
row("timer", String(localized: "시간 기록"), String(localized: "\(visibleSessionCount)건"))
|
||||
row("number", String(localized: "횟수 기록"), String(localized: "\(visibleEntryCount)건"))
|
||||
row("flag.checkered", String(localized: "목표·다짐"), String(localized: "\(goals.count)개"))
|
||||
} header: {
|
||||
Text("내보낼 데이터")
|
||||
@ -64,8 +122,18 @@ struct DataExportView: View {
|
||||
.navigationTitle("데이터 내보내기")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar(.hidden, for: .tabBar)
|
||||
.onChange(of: rangeMode) { resultURLs = [] }
|
||||
.onChange(of: startDate) { resultURLs = [] }
|
||||
.onChange(of: endDate) { resultURLs = [] }
|
||||
#if DEBUG
|
||||
.onAppear {
|
||||
// 검증용: -dataExportRangeDays <N> → 최근 N일 구간 지정 상태로 시작
|
||||
let rangeDays = UserDefaults.standard.integer(forKey: "dataExportRangeDays")
|
||||
if rangeDays > 0 {
|
||||
rangeMode = .custom
|
||||
startDate = math.calendar.date(byAdding: .day, value: -(rangeDays - 1), to: .now) ?? .now
|
||||
endDate = .now
|
||||
}
|
||||
// 검증용: -dataExportRun YES → 자동 생성 후 Documents에 결과 복사
|
||||
if UserDefaults.standard.bool(forKey: "dataExportRun") {
|
||||
export()
|
||||
@ -87,66 +155,21 @@ struct DataExportView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: CSV 생성
|
||||
// MARK: CSV 생성 (코어는 CSVExport — 초기화 백업과 공유)
|
||||
|
||||
private func export() {
|
||||
isRendering = true
|
||||
Task { @MainActor in
|
||||
var urls: [URL?] = []
|
||||
// 파일명·헤더(열 이름)는 언어별 현지화 — 수신 환경이 한글을 지원하지 않을 수 있고,
|
||||
// 스프레드시트에서 열 이름은 곧 UI다. 값 중 rawValue(time/count 등)는 데이터라 그대로.
|
||||
urls.append(write(String(localized: "행동"), header: [
|
||||
"id", String(localized: "이름"), String(localized: "추적 방식"),
|
||||
String(localized: "꼬리표"), String(localized: "즐겨찾기"), String(localized: "만든날"),
|
||||
],
|
||||
rows: actions.map { action in
|
||||
[action.uuid.uuidString, action.name,
|
||||
action.trackingType == .time ? "time" : "count",
|
||||
action.sortedTags.map(\.name).joined(separator: "; "),
|
||||
action.isFavorite ? "true" : "false",
|
||||
iso(action.createdAt)]
|
||||
}))
|
||||
urls.append(write(String(localized: "시간기록", comment: "CSV 파일명용 (공백 없이)"), header: [
|
||||
String(localized: "행동id"), String(localized: "행동"), String(localized: "시작"),
|
||||
String(localized: "종료"), String(localized: "초"), String(localized: "메모"),
|
||||
],
|
||||
rows: sessions.compactMap { session in
|
||||
guard let action = session.action else { return nil }
|
||||
return [action.uuid.uuidString, action.name,
|
||||
iso(session.startAt),
|
||||
session.endAt.map(iso) ?? "",
|
||||
String(Int(session.duration())),
|
||||
session.note]
|
||||
}))
|
||||
urls.append(write(String(localized: "횟수기록", comment: "CSV 파일명용 (공백 없이)"), header: [
|
||||
String(localized: "행동id"), String(localized: "행동"), String(localized: "시각"),
|
||||
String(localized: "수량"), String(localized: "메모"),
|
||||
],
|
||||
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(String(localized: "목표"), header: [
|
||||
"id", String(localized: "내용"), String(localized: "시작일"),
|
||||
String(localized: "종료일"), String(localized: "상태"), String(localized: "달성기준%"),
|
||||
],
|
||||
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(String(localized: "다짐"), header: [
|
||||
"id", String(localized: "목표"), String(localized: "대상"),
|
||||
String(localized: "주기"), String(localized: "목표량"), String(localized: "방향"),
|
||||
],
|
||||
rows: goals.flatMap(\.sortedQuests).map { quest in
|
||||
[quest.uuid.uuidString, quest.goal?.title ?? "",
|
||||
quest.targetName, quest.scheduleLabel,
|
||||
quest.measure == .time ? String(Int(quest.targetSeconds)) + "s" : String(quest.targetCount),
|
||||
quest.direction == .atLeast ? "atLeast" : "atMost"]
|
||||
}))
|
||||
resultURLs = urls.compactMap(\.self)
|
||||
let range = selectedRange
|
||||
let suffix = range.map { CSVExport.fileSuffix(for: $0, math: math) } ?? ""
|
||||
resultURLs = CSVExport.makeFiles(
|
||||
actions: actions,
|
||||
sessions: sessions,
|
||||
entries: entries,
|
||||
goals: goals,
|
||||
range: range,
|
||||
fileSuffix: suffix
|
||||
)
|
||||
isRendering = false
|
||||
#if DEBUG
|
||||
if UserDefaults.standard.bool(forKey: "dataExportRun"),
|
||||
@ -160,31 +183,4 @@ struct DataExportView: View {
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
private func iso(_ date: Date) -> String {
|
||||
date.formatted(.iso8601)
|
||||
}
|
||||
|
||||
/// CSV 필드 이스케이프 (쉼표·따옴표·줄바꿈 포함 시 따옴표로 감쌈)
|
||||
private func escape(_ field: String) -> String {
|
||||
if field.contains(",") || field.contains("\"") || field.contains("\n") {
|
||||
return "\"" + field.replacingOccurrences(of: "\"", with: "\"\"") + "\""
|
||||
}
|
||||
return field
|
||||
}
|
||||
|
||||
private func write(_ name: String, header: [String], rows: [[String]]) -> URL? {
|
||||
let lines = [header.map(escape).joined(separator: ",")]
|
||||
+ rows.map { $0.map(escape).joined(separator: ",") }
|
||||
// 엑셀이 UTF-8 한글을 바로 인식하도록 BOM을 붙인다
|
||||
let content = "\u{FEFF}" + lines.joined(separator: "\n")
|
||||
let url = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(String(localized: "하루다님-\(name).csv"))
|
||||
do {
|
||||
try content.write(to: url, atomically: true, encoding: .utf8)
|
||||
return url
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user