From 8c93da5ff380f8af1aff2f85d1e82d57f56b4c28 Mon Sep 17 00:00:00 2001 From: songyc macbook Date: Tue, 11 Aug 2026 16:48:01 +0900 Subject: [PATCH] =?UTF-8?q?feat(export):=20CSV=20=EB=82=B4=EB=B3=B4?= =?UTF-8?q?=EB=82=B4=EA=B8=B0=20=EA=B5=AC=EA=B0=84=20=EC=A7=80=EC=A0=95=20?= =?UTF-8?q?=E2=80=94=20=EA=B8=B0=EB=A1=9D=EB=A7=8C=20=EA=B1=B8=EB=9F=AC?= =?UTF-8?q?=EB=82=B4=EA=B3=A0=20=EA=B5=AC=EC=A1=B0=EB=8A=94=20=EC=A0=84?= =?UTF-8?q?=EC=B2=B4=20=EC=9C=A0=EC=A7=80,=20=EC=BD=94=EC=96=B4=EB=A5=BC?= =?UTF-8?q?=20CSVExport=EB=A1=9C=20=EC=B6=94=EC=B6=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 기존 동작 검증: 전량 내보내기(무필터)였음을 확인 후 '전체(기본)/직접 지정' 세그먼트 추가 - 세션은 구간 겹침 포함(기록 탭 범위 문법), 구간 파일명 접미 -yyyyMMdd-yyyyMMdd - 행동·목표·다짐 목록은 항상 전체(가져오기 연결·백업 온전성) — footer로 안내 - CSV 생성 코어 CSVExport 신설: 데이터 정리·초기화(예정)의 삭제 전 백업이 재사용 - -dataExportRangeDays 검증 인자, 전체(2031·8044건)↔최근 7일(4·1건) 실파일 대조 확인 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01NNxA172hNDRpDsJ2qztKPf --- myApp/HaruDanim/IOS/Core/CSVExport.swift | 131 ++++++++++++++ .../HaruDanim/IOS/Views/DataExportView.swift | 166 +++++++++--------- 2 files changed, 212 insertions(+), 85 deletions(-) create mode 100644 myApp/HaruDanim/IOS/Core/CSVExport.swift diff --git a/myApp/HaruDanim/IOS/Core/CSVExport.swift b/myApp/HaruDanim/IOS/Core/CSVExport.swift new file mode 100644 index 0000000..93210b0 --- /dev/null +++ b/myApp/HaruDanim/IOS/Core/CSVExport.swift @@ -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? = 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, 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 + } + } +} diff --git a/myApp/HaruDanim/IOS/Views/DataExportView.swift b/myApp/HaruDanim/IOS/Views/DataExportView.swift index e0cf2a4..527050a 100644 --- a/myApp/HaruDanim/IOS/Views/DataExportView.swift +++ b/myApp/HaruDanim/IOS/Views/DataExportView.swift @@ -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? { + 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.. 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일 구간 지정 상태로 시작 + 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 - } - } }