mycode/myApp/HaruDanim/IOS/Core/CSVImport.swift
songyc macbook 2dc81fe0e5 feat: 버전 1.1 — 맥 일기 개방 + CSV 가져오기(프리미엄) + 스크린샷 전면 개편
맥 일기 (§6.7):
- 사이드바에 일기 탭 개방 (AppTab.sidebarCases 맥 필터 제거)
- 맥 노트 도구줄: 텍스트 우선 가로 스크롤 + '그리기' 토글(마우스·트랙패드 필기, drawingPolicy anyInput)
- 그리기↔배치 상호 배타, 일기 잠금 설정 맥 노출 — 아이패드 경로는 코드 불변(회귀 차단)

CSV 가져오기 (§6.8·프리미엄):
- IOS/Core/CSVImport.swift: RFC4180 파서·데이터 행 모양 판별(언어 무관)·계획/반영 분리·초 단위 멱등 중복 처리
- 행동 연결: uuid 우선 → 이름+방식 폴백 → 새로 생성(uuid 보존), '측정 중' 행 제외, 추가 전용
- DataImportView: 파일 선택(복수) → 미리 보기 → 확인 → 저장 1회. 무료 한도 우회 방지 위해 프리미엄 게이트
- 자가 테스트 -csvImportTest 20건 ALL PASS, 진행률 자가 테스트 회귀 ALL PASS

문서·현지화·버전:
- 도움말(일기 맥·CSV 복원)·프리미엄 기능 행·설정 문구 갱신, en/ja 42키 번역·stale 10키 정리 (missing 0)
- CLAUDE.md §1/§6/§6.7/§6.8/§8/§14, Marketing 설명 ko/en/ja·whats-new-1.1, MARKETING_VERSION 1.1(4타깃)

스크린샷 전면 재제작 (ko/en/ja):
- 아이폰 10장·아이패드 10장(신규: 기록 목록/주간 타임테이블/일기 양식/다크/목표)·워치 합성 1장
- 시간대 정합: SIMCTL_CHILD_TZ 부팅 주입(Asia/Bangkok) + -marketingTZ 프로브 검증 절차 확립(README)
- frame_screenshots.swift: 문구 5종 추가·일기/동기화 문구 맥 반영·renderWatch 합성 추가 (총 63장)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FVeduv1eNdXjk1ay4tSgBg
2026-07-29 11:30:25 +09:00

585 lines
27 KiB
Swift

//
// 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<String> {
var keys = Set<String>()
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<Action>())) ?? []
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<UUID>()
var linkedIDs = Set<UUID>()
/// · 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<Action>())) ?? []
var byID: [UUID: Action] = [:]
for action in existingActions { byID[action.uuid] = action }
// ( )
let existingTags = (try? context.fetch(FetchDescriptor<Tag>())) ?? []
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<UUID>()
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