// // DataExportView.swift // Haru_Danim // // 원시 데이터 내보내기 (설정 → 데이터). // 모든 기록을 CSV 파일로 만들어 공유한다 — 백업·이동·스프레드시트 분석용. // 리포트 이미지(ExportImageView)와 달리 가공 없는 원본 값을 담는다. // 무료 사용자도 사용 가능 (자기 데이터에 대한 접근권). // import SwiftUI import SwiftData struct DataExportView: View { @Query(sort: \Action.createdAt) private var actions: [Action] @Query(sort: \TimeSession.startAt) private var sessions: [TimeSession] @Query(sort: \CountEntry.timestamp) private var entries: [CountEntry] @Query(sort: [SortDescriptor(\Goal.sortOrder), SortDescriptor(\Goal.createdAt)]) private var goals: [Goal] @State private var isRendering = false @State private var resultURLs: [URL] = [] var body: some View { List { 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("flag.checkered", String(localized: "목표·다짐"), String(localized: "\(goals.count)개")) } header: { Text("내보낼 데이터") } footer: { Text("행동·시간 기록·횟수 기록·목표·다짐을 각각 CSV 파일로 만들어요. 스프레드시트에서 열거나 백업으로 보관할 수 있어요. 시각은 ISO 8601 형식이에요.") } Section { if isRendering { HStack { ProgressView() Text("만드는 중…") .foregroundStyle(.secondary) } } else if resultURLs.isEmpty { Button { export() } label: { Label("CSV 만들기", systemImage: "tablecells") .frame(maxWidth: .infinity) } } else { ShareLink(items: resultURLs) { Label("파일 \(resultURLs.count)개 공유", systemImage: "square.and.arrow.up") .frame(maxWidth: .infinity) } Button("다시 만들기") { resultURLs = [] } .frame(maxWidth: .infinity) } } } .tint(AppTheme.green) .scrollContentBackground(.hidden) .background(AppTheme.background) .navigationTitle("데이터 내보내기") .navigationBarTitleDisplayMode(.inline) .toolbar(.hidden, for: .tabBar) #if DEBUG .onAppear { // 검증용: -dataExportRun YES → 자동 생성 후 Documents에 결과 복사 if UserDefaults.standard.bool(forKey: "dataExportRun") { export() } } #endif } private func row(_ symbol: String, _ title: String, _ count: String) -> some View { HStack { Label { Text(title) } icon: { Image(systemName: symbol) .foregroundStyle(AppTheme.green) } Spacer() Text(count).foregroundStyle(.secondary) } } // MARK: CSV 생성 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) isRendering = false #if DEBUG if UserDefaults.standard.bool(forKey: "dataExportRun"), let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first { for url in resultURLs { let dest = docs.appendingPathComponent(url.lastPathComponent) try? FileManager.default.removeItem(at: dest) try? FileManager.default.copyItem(at: url, to: dest) } } #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 } } }