// // CSVImport.swift // Haru_Danim // // CSV 데이터 가져오기 로직 (설정 → 데이터 → 데이터 가져오기, 프리미엄 — CLAUDE.md §7.1) // 내보내기(DataExportView)가 만든 CSV에서 행동·시간 기록·횟수 기록을 복원한다. // // 원칙: // - **추가 전용(additive-only)**: 기존 데이터를 수정·삭제하지 않는다 — 최악의 결함도 // "불필요한 추가"에 그치고, 충돌은 전부 '건너뜀'으로 처리한다. // - **미리 보기 → 확인 → 1회 저장**: 계획(plan)과 반영(apply)을 분리하고, 반영 시점에 // 중복 검사를 다시 수행해 미리 보기 이후의 데이터 변화에도 안전하다. // - **멱등**: 같은 파일을 두 번 가져와도 중복 기록이 생기지 않는다 — 내보내기가 초 단위라 // 기존 기록의 소수점 초와 어긋나는 유사 중복까지 잡도록 초 단위 절사로 비교한다. // - **언어 무관**: 파일 종류는 헤더 문자열(언어별)이 아니라 데이터 행의 모양(UUID·ISO 날짜· // time/count 원시값)으로 판별한다 — 내보낸 언어와 가져오는 언어가 달라도 동작. // - 목표·다짐 CSV는 대상이 아니다: 다짐 CSV는 주기가 표시 문자열이라 재구성 불가하고, // 기록만 복원되면 목표·다짐은 기간을 걸쳐 새로 만들 때 자동으로 집계된다 (2026-07-29 결정). // - '측정 중'(종료 없음) 행은 건너뛴다 — 가져오기로 진행 중 측정이 생기면 Live Activity· // 위젯이 유령 타이머를 띄우게 되기 때문. // import Foundation import SwiftData @MainActor enum CSVImport { // MARK: 파일 종류 enum FileKind { case actions, sessions, counts var label: String { switch self { case .actions: return String(localized: "행동 파일") case .sessions: return String(localized: "시간 기록 파일") case .counts: return String(localized: "횟수 기록 파일") } } } /// 읽기에 성공해 종류까지 판별된 파일 (미리 보기와 반영이 같은 원본을 쓴다) struct LoadedFile { let name: String let kind: FileKind /// 헤더 행을 제외한 데이터 행들 let rows: [[String]] } // MARK: RFC 4180 파서 (따옴표·쉼표·줄바꿈 이스케이프, BOM 제거) nonisolated static func parse(_ text: String) -> [[String]] { var s = text if s.hasPrefix("\u{FEFF}") { s.removeFirst() } var rows: [[String]] = [] var row: [String] = [] var field = "" var inQuotes = false var i = s.startIndex while i < s.endIndex { let c = s[i] if inQuotes { if c == "\"" { let next = s.index(after: i) if next < s.endIndex, s[next] == "\"" { field.append("\"") i = next } else { inQuotes = false } } else { field.append(c) } } else { switch c { case "\"": inQuotes = true case ",": row.append(field); field = "" case "\r": break case "\n": row.append(field); field = ""; rows.append(row); row = [] default: field.append(c) } } i = s.index(after: i) } if !field.isEmpty || !row.isEmpty { row.append(field) rows.append(row) } // 완전히 빈 줄(마지막 개행 등)은 제거 return rows.filter { !($0.count == 1 && $0[0].isEmpty) } } // MARK: 날짜 파싱 (내보내기의 ISO 8601 형식) private nonisolated static let isoBasic: ISO8601DateFormatter = { let f = ISO8601DateFormatter() f.formatOptions = [.withInternetDateTime] return f }() private nonisolated static let isoFractional: ISO8601DateFormatter = { let f = ISO8601DateFormatter() f.formatOptions = [.withInternetDateTime, .withFractionalSeconds] return f }() nonisolated static func date(_ raw: String) -> Date? { let s = raw.trimmingCharacters(in: .whitespaces) guard !s.isEmpty else { return nil } return isoBasic.date(from: s) ?? isoFractional.date(from: s) } // MARK: 종류 판별 (헤더가 아니라 데이터 행 모양으로 — 언어 무관) /// 첫 유효 데이터 행 기준: /// - 6열 + UUID + 3열이 "time"/"count" → 행동 /// - 6열 + UUID + 3열이 ISO 날짜 → 시간 기록 /// - 5열 + UUID + 3열이 ISO 날짜 + 4열 정수 → 횟수 기록 /// (헤더 행은 UUID 파싱에 실패해 자연히 건너뛰어진다. 데이터가 없으면 nil — 가져올 것도 없음) nonisolated static func classify(_ rows: [[String]]) -> FileKind? { for row in rows { guard row.count >= 5, UUID(uuidString: row[0].trimmingCharacters(in: .whitespaces)) != nil else { continue } if row.count >= 6 { let third = row[2].trimmingCharacters(in: .whitespaces) if third == "time" || third == "count" { return .actions } if date(row[2]) != nil { return .sessions } return nil } if date(row[2]) != nil, Int(row[3].trimmingCharacters(in: .whitespaces)) != nil { return .counts } return nil } return nil } // MARK: 계획 (미리 보기용 값 타입 — 모델 생성은 apply에서만) struct ActionStub { let id: UUID var name: String var isCount: Bool var isFavorite: Bool var tagNames: [String] var createdAt: Date? } struct SessionStub { let actionID: UUID let start: Date let end: Date let note: String } struct CountStub { let actionID: UUID let timestamp: Date let amount: Int let note: String } struct Plan { var newActions: [ActionStub] = [] /// CSV의 uuid가 그대로 존재해 이어진 행동 수 var matchedByID = 0 /// uuid는 없지만 이름·방식이 같아 기존 행동으로 이어진 행동 수 var linkedByName = 0 var newTagNames: [String] = [] var sessions: [SessionStub] = [] var counts: [CountStub] = [] var duplicateCount = 0 var runningSkipped = 0 var invalidCount = 0 /// 문제 행 안내 (앞 몇 개만 UI에 표시) var invalidSamples: [String] = [] var fileSummaries: [String] = [] var recordCount: Int { sessions.count + counts.count } var hasWork: Bool { !newActions.isEmpty || recordCount > 0 } } struct Summary { var actionsCreated = 0 var tagsCreated = 0 var sessionsAdded = 0 var countsAdded = 0 var duplicatesSkipped = 0 } /// 기존 기록과의 중복 판정 키 (초 단위 절사) private nonisolated static func sessionKey(_ actionID: UUID, _ start: Date, _ end: Date) -> String { "s|\(actionID.uuidString)|\(Int(start.timeIntervalSince1970))|\(Int(end.timeIntervalSince1970))" } private nonisolated static func countKey(_ actionID: UUID, _ ts: Date, _ amount: Int) -> String { "c|\(actionID.uuidString)|\(Int(ts.timeIntervalSince1970))|\(amount)" } /// 대상 행동들의 기존 기록 키 집합 (중복 건너뜀용) private static func existingKeys(for actions: [Action]) -> Set { var keys = Set() for action in actions { for session in action.sessions { guard let end = session.endAt else { continue } keys.insert(sessionKey(action.uuid, session.startAt, end)) } for entry in action.countEntries { keys.insert(countKey(action.uuid, entry.timestamp, entry.amount)) } } return keys } /// 파일들을 읽어 가져오기 계획을 만든다. DB는 조회만 하고 변경하지 않는다. static func makePlan(files: [LoadedFile], context: ModelContext) -> Plan { var plan = Plan() let existingActions = (try? context.fetch(FetchDescriptor())) ?? [] var byID: [UUID: Action] = [:] for action in existingActions { byID[action.uuid] = action } /// CSV uuid → 최종 대상 uuid (기존 행동 또는 새로 만들 스텁) var mapping: [UUID: UUID] = [:] var stubsByID: [UUID: Int] = [:] // plan.newActions 인덱스 var matchedIDs = Set() var linkedIDs = Set() /// 이름·방식 폴백 매칭 — uuid가 없어도 같은 이름·같은 방식의 기존 행동이 있으면 잇는다 /// (앱을 재설치하고 행동을 다시 만든 경우 등. 동명이 여럿이면 먼저 만든 것) func nameMatch(_ name: String, isCount: Bool) -> Action? { existingActions .filter { $0.name == name && ($0.trackingType == .count) == isCount } .sorted { $0.createdAt < $1.createdAt } .first } /// csv 행동을 대상 uuid로 해석 (필요하면 새 스텁 등록) func resolve(id: UUID, name: String, isCount: Bool, favorite: Bool = false, tags: [String] = [], createdAt: Date? = nil) -> UUID { if let mapped = mapping[id] { return mapped } if let existing = byID[id] { mapping[id] = existing.uuid matchedIDs.insert(id) return existing.uuid } if let named = nameMatch(name, isCount: isCount) { mapping[id] = named.uuid linkedIDs.insert(id) return named.uuid } // 새 행동 스텁 (csv uuid 보존 — 재가져오기·기기 간 멱등성) mapping[id] = id stubsByID[id] = plan.newActions.count plan.newActions.append(ActionStub( id: id, name: name, isCount: isCount, isFavorite: favorite, tagNames: tags, createdAt: createdAt )) return id } // ① 행동 파일 먼저 — 이름·즐겨찾기·꼬리표 정보가 있는 매핑을 우선 확보 for file in files where file.kind == .actions { var count = 0 for (index, row) in file.rows.enumerated() { guard row.count >= 6, let id = UUID(uuidString: row[0].trimmingCharacters(in: .whitespaces)) else { continue } let name = row[1].trimmingCharacters(in: .whitespacesAndNewlines) let typeRaw = row[2].trimmingCharacters(in: .whitespaces) guard !name.isEmpty, typeRaw == "time" || typeRaw == "count" else { plan.invalidCount += 1 if plan.invalidSamples.count < 5 { plan.invalidSamples.append(String(localized: "\(file.name) \(index + 1)행: 행동 이름 또는 방식이 잘못됐어요")) } continue } let tags = row[3].split(separator: ";").map { $0.trimmingCharacters(in: .whitespaces) } .filter { !$0.isEmpty } let stubIndexBefore = plan.newActions.count _ = resolve(id: id, name: name, isCount: typeRaw == "count", favorite: row[4].trimmingCharacters(in: .whitespaces) == "true", tags: tags, createdAt: date(row[5])) // 새 스텁으로 등록된 경우에만 꼬리표 생성 후보 수집 (기존 행동은 건드리지 않음) if plan.newActions.count > stubIndexBefore { for tag in tags where !plan.newTagNames.contains(tag) { plan.newTagNames.append(tag) } } count += 1 } plan.fileSummaries.append(String(localized: "\(file.kind.label) · \(count)행")) } // ② 기록 파일 — 대상 행동을 해석하며 스텁 자동 생성, 중복·불량 행 판별 let targetIDs = Set(mapping.values) var involvedActions = existingActions.filter { targetIDs.contains($0.uuid) } // 기록 파일이 참조하는 기존 행동도 중복 검사 대상에 포함해야 하므로 아래에서 lazy하게 추가 var keys = existingKeys(for: involvedActions) var includedKeySet = Set(involvedActions.map(\.uuid)) func includeExistingKeysIfNeeded(_ uuid: UUID) { guard !includedKeySet.contains(uuid), let action = byID[uuid] else { return } includedKeySet.insert(uuid) involvedActions.append(action) keys.formUnion(existingKeys(for: [action])) } for file in files where file.kind != .actions { var added = 0 for (index, row) in file.rows.enumerated() { guard let id = UUID(uuidString: row[0].trimmingCharacters(in: .whitespaces)) else { continue } let name = row[1].trimmingCharacters(in: .whitespacesAndNewlines) if file.kind == .sessions { guard row.count >= 6 else { continue } guard let start = date(row[2]) else { plan.invalidCount += 1 if plan.invalidSamples.count < 5 { plan.invalidSamples.append(String(localized: "\(file.name) \(index + 1)행: 시작 시각을 읽을 수 없어요")) } continue } if row[3].trimmingCharacters(in: .whitespaces).isEmpty { // 내보낼 당시 '측정 중'이던 기록 — 가져오면 유령 측정이 되므로 제외 plan.runningSkipped += 1 continue } guard let end = date(row[3]), end > start else { plan.invalidCount += 1 if plan.invalidSamples.count < 5 { plan.invalidSamples.append(String(localized: "\(file.name) \(index + 1)행: 종료 시각이 잘못됐어요")) } continue } let target = resolve(id: id, name: name.isEmpty ? String(localized: "가져온 행동") : name, isCount: false) includeExistingKeysIfNeeded(target) let key = sessionKey(target, start, end) guard !keys.contains(key) else { plan.duplicateCount += 1 continue } keys.insert(key) plan.sessions.append(SessionStub(actionID: target, start: start, end: end, note: row.count > 5 ? row[5] : "")) added += 1 } else { guard row.count >= 5 else { continue } guard let ts = date(row[2]) else { plan.invalidCount += 1 if plan.invalidSamples.count < 5 { plan.invalidSamples.append(String(localized: "\(file.name) \(index + 1)행: 기록 시각을 읽을 수 없어요")) } continue } guard let amount = Int(row[3].trimmingCharacters(in: .whitespaces)), (1...9999).contains(amount) else { plan.invalidCount += 1 if plan.invalidSamples.count < 5 { plan.invalidSamples.append(String(localized: "\(file.name) \(index + 1)행: 수량이 잘못됐어요")) } continue } let target = resolve(id: id, name: name.isEmpty ? String(localized: "가져온 행동") : name, isCount: true) includeExistingKeysIfNeeded(target) let key = countKey(target, ts, amount) guard !keys.contains(key) else { plan.duplicateCount += 1 continue } keys.insert(key) plan.counts.append(CountStub(actionID: target, timestamp: ts, amount: amount, note: row.count > 4 ? row[4] : "")) added += 1 } } plan.fileSummaries.append(String(localized: "\(file.kind.label) · \(added)행")) } plan.matchedByID = matchedIDs.count plan.linkedByName = linkedIDs.count return plan } /// 계획을 실제로 반영한다. 중복 검사는 최신 DB 기준으로 다시 수행하고, /// 삽입 후 저장은 호출부의 DataChange.commit 1회에 맡긴다. @discardableResult static func apply(_ plan: Plan, context: ModelContext) -> Summary { var summary = Summary() let existingActions = (try? context.fetch(FetchDescriptor())) ?? [] var byID: [UUID: Action] = [:] for action in existingActions { byID[action.uuid] = action } // 꼬리표 (새 행동용 — 기존 꼬리표는 이름으로 재사용) let existingTags = (try? context.fetch(FetchDescriptor())) ?? [] var tagsByName: [String: Tag] = [:] for tag in existingTags where tagsByName[tag.name] == nil { tagsByName[tag.name] = tag } var tagOrder = (existingTags.map(\.sortOrder).max() ?? -1) + 1 var tagCount = existingTags.count func tag(named name: String) -> Tag { if let found = tagsByName[name] { return found } let preset = AppTheme.tagPresets[tagCount % AppTheme.tagPresets.count] let created = Tag(name: name, colorHex: preset) created.sortOrder = tagOrder tagOrder += 1 tagCount += 1 context.insert(created) tagsByName[name] = created summary.tagsCreated += 1 return created } // 새 행동 생성 (uuid 보존) var actionOrder = (existingActions.map(\.sortOrder).max() ?? -1) + 1 for stub in plan.newActions where byID[stub.id] == nil { let action = Action( name: stub.name, symbolName: stub.isCount ? "number" : "timer", trackingType: stub.isCount ? .count : .time, sortOrder: actionOrder ) actionOrder += 1 action.uuid = stub.id action.isFavorite = stub.isFavorite if let created = stub.createdAt { action.createdAt = created } action.tags = stub.tagNames.map { tag(named: $0) } context.insert(action) byID[stub.id] = action LocalPrefs.appendActionToOrder(stub.id) summary.actionsCreated += 1 } // 기록 삽입 — 반영 시점 기준으로 중복 재검사 (미리 보기 이후 변화 방어) var involved: [Action] = [] var involvedIDs = Set() for id in Set(plan.sessions.map(\.actionID) + plan.counts.map(\.actionID)) { if let action = byID[id], involvedIDs.insert(id).inserted { involved.append(action) } } var keys = existingKeys(for: involved) for stub in plan.sessions { guard let action = byID[stub.actionID] else { continue } let key = sessionKey(stub.actionID, stub.start, stub.end) guard !keys.contains(key) else { summary.duplicatesSkipped += 1 continue } keys.insert(key) let session = TimeSession(action: action, startAt: stub.start, endAt: stub.end) session.note = stub.note context.insert(session) summary.sessionsAdded += 1 } for stub in plan.counts { guard let action = byID[stub.actionID] else { continue } let key = countKey(stub.actionID, stub.timestamp, stub.amount) guard !keys.contains(key) else { summary.duplicatesSkipped += 1 continue } keys.insert(key) let entry = CountEntry(action: action, timestamp: stub.timestamp, amount: stub.amount) entry.note = stub.note context.insert(entry) summary.countsAdded += 1 } return summary } } // MARK: - 자가 테스트 (DEBUG, -csvImportTest YES) #if DEBUG extension CSVImport { /// 인메모리 컨테이너에 픽스처 CSV를 흘려 파서·판별·계획·반영·멱등성을 검증한다. /// 결과는 Documents/csv-import-test.txt (실데이터 무영향). 픽스처 문자열은 개발용 데이터라 /// String(localized:)를 쓰지 않는다 (카탈로그 오염 방지, §2.3). static func selfTestIfRequested() { guard UserDefaults.standard.bool(forKey: "csvImportTest") else { return } var lines: [String] = [] var failures = 0 func expect(_ label: String, _ actual: Int, _ expected: Int) { let pass = actual == expected if !pass { failures += 1 } lines.append("\(pass ? "PASS" : "FAIL") \(label): actual \(actual) / expected \(expected)") } do { let config = ModelConfiguration(isStoredInMemoryOnly: true) let container = try ModelContainer(for: DataStore.schema, configurations: [config]) let context = container.mainContext // 기존 데이터: uuid A 행동(시간형, 세션 1개) + 이름 매칭용 횟수형 행동 let idA = UUID() let preA = Action(name: "복원독서", symbolName: "book.fill", trackingType: .time, sortOrder: 0) preA.uuid = idA context.insert(preA) let s0 = Date(timeIntervalSince1970: 1_700_000_000) let s1 = s0.addingTimeInterval(3600) context.insert(TimeSession(action: preA, startAt: s0, endAt: s1)) let preNamed = Action(name: "기존물", symbolName: "drop.fill", trackingType: .count, sortOrder: 1) context.insert(preNamed) try context.save() let idB = UUID(), idC = UUID(), idD = UUID() func iso(_ d: Date) -> String { d.formatted(.iso8601) } let actionsCSV = """ id,name,type,tags,fav,created \(idA.uuidString),복원독서,time,,true,\(iso(s0)) \(idB.uuidString),복원물,count,"생활; 건강",false,\(iso(s0)) """ let sessionsCSV = """ actionId,action,start,end,sec,memo \(idA.uuidString),복원독서,\(iso(s0)),\(iso(s1)),3600,중복행 \(idA.uuidString),복원독서,\(iso(s1)),\(iso(s1.addingTimeInterval(1800))),1800,"쉼표, 그리고 줄바꿈 메모" \(idA.uuidString),복원독서,\(iso(s1)),\(iso(s0)),0,역전행 \(idA.uuidString),복원독서,\(iso(s0)),,999,측정중행 \(idC.uuidString),복원달리기,\(iso(s0)),\(iso(s0.addingTimeInterval(600))),600, """ let countsCSV = """ actionId,action,ts,amount,memo \(idB.uuidString),복원물,\(iso(s0)),3, \(idB.uuidString),복원물,\(iso(s0)),3, \(idB.uuidString),복원물,\(iso(s1)),0,수량0 \(idD.uuidString),기존물,\(iso(s1)),2,이름연결 """ let files: [LoadedFile] = [ LoadedFile(name: "actions.csv", kind: classify(parse(actionsCSV))!, rows: parse(actionsCSV)), LoadedFile(name: "sessions.csv", kind: classify(parse(sessionsCSV))!, rows: parse(sessionsCSV)), LoadedFile(name: "counts.csv", kind: classify(parse(countsCSV))!, rows: parse(countsCSV)), ] expect("판별: 행동", files[0].kind == .actions ? 1 : 0, 1) expect("판별: 시간", files[1].kind == .sessions ? 1 : 0, 1) expect("판별: 횟수", files[2].kind == .counts ? 1 : 0, 1) let plan = makePlan(files: files, context: context) expect("uuid 일치 연결", plan.matchedByID, 1) // A expect("이름 폴백 연결", plan.linkedByName, 1) // D → 기존물 expect("새 행동", plan.newActions.count, 2) // B, C expect("새 꼬리표 후보", plan.newTagNames.count, 2) // 생활, 건강 expect("추가할 시간 기록", plan.sessions.count, 2) // 따옴표행 + C행 expect("추가할 횟수 기록", plan.counts.count, 2) // B 1건 + D 1건 expect("중복 건너뜀", plan.duplicateCount, 2) // A 중복행 + B 반복행 expect("측정 중 건너뜀", plan.runningSkipped, 1) expect("불량 행", plan.invalidCount, 2) // 역전행 + 수량0 // 따옴표·줄바꿈 메모 보존 확인 let memoOK = plan.sessions.contains { $0.note.contains("쉼표, 그리고\n줄바꿈") } expect("따옴표 메모 보존", memoOK ? 1 : 0, 1) let summary = apply(plan, context: context) try context.save() expect("생성된 행동", summary.actionsCreated, 2) expect("생성된 꼬리표", summary.tagsCreated, 2) expect("추가된 시간 기록", summary.sessionsAdded, 2) expect("추가된 횟수 기록", summary.countsAdded, 2) // 멱등성: 같은 파일을 다시 계획하면 전부 중복/기존 처리 let replan = makePlan(files: files, context: context) expect("재가져오기: 새 행동 없음", replan.newActions.count, 0) expect("재가져오기: 새 기록 없음", replan.recordCount, 0) // 시간 3행(중복행·따옴표행·C행) + 횟수 3행(B 2행·D 1행) 전부 중복 처리 expect("재가져오기: 중복 처리", replan.duplicateCount, 6) } catch { failures += 1 lines.append("FAIL 컨테이너: \(error)") } lines.append(failures == 0 ? "== ALL PASS ==" : "== \(failures) FAILURES ==") for line in lines { print("[CSVImportTest] \(line)") } if let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first { try? lines.joined(separator: "\n") .write(to: docs.appendingPathComponent("csv-import-test.txt"), atomically: true, encoding: .utf8) } } } #endif