- 기존 동작 검증: 전량 내보내기(무필터)였음을 확인 후 '전체(기본)/직접 지정' 세그먼트 추가 - 세션은 구간 겹침 포함(기록 탭 범위 문법), 구간 파일명 접미 -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
132 lines
6.4 KiB
Swift
132 lines
6.4 KiB
Swift
//
|
|
// 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
|
|
}
|
|
}
|
|
}
|