mycode/myApp/HaruDanim/IOS/Core/DataReset.swift
songyc macbook cf78a3ac7e feat(data): 데이터 정리·초기화 — 전체/처음~날짜까지 + 삭제 전 백업(CSV·일기 PDF·정리 리포트)
- DataReset 코어: 범위 삭제(완전 포함 세션만·경계 걸침/진행 중 보존·일기 dayKey≤키·구조/양식 보존), 전체 초기화(cascade+고아 정리+LocalPrefs 6키), 컨텍스트 경유 개별 삭제(CloudKit 전파 안전)
- ArchiveReport: 삭제 범위 총정리 다페이지 포스터(타이틀+기간(첫~마지막 기록일)+히어로+행동별 누적 표+꼬리표 합계+월별 표+목표 이력, 24행 청크) → PDF/스티치 PNG, ExportPosterView 재사용
- DataResetView(설정→데이터, 무료): 범위 세그먼트+삭제 대상 fetchCount+백업 토글 3종+2단계 확인(다이얼로그→얼럿)+iCloud·연속 끊김 경고+완료 요약
- 검증: -dataResetTest 인메모리 18건 ALL PASS, -dataResetBackupRun로 CSV 5종+3페이지 리포트 PDF 실물 확인(기간 라벨 2025-03-23~오늘), 화면 UI 확인
- 신규 인자: -dataResetPreview·-dataResetBackupRun pdf|image·-dataResetRangeDays·-dataResetTest

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NNxA172hNDRpDsJ2qztKPf
2026-08-11 17:11:51 +09:00

224 lines
12 KiB
Swift
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// DataReset.swift
// Haru_Danim
//
// ·(1.4) .
// CloudKit .
// (§5.1). ·
// DataChange.commit .
//
// ( , 1.4):
// - " " · , (··
// ·)
// - ( )
// ,
// - ( )
//
import Foundation
import SwiftData
enum DataReset {
struct Counts {
var sessions = 0
var entries = 0
var diaryEntries = 0
var actions = 0
var tags = 0
var goals = 0
var templates = 0
}
// MARK: ( ~ endDayKey)
/// endDayKey( , ) · . .
@MainActor
static func deleteRecords(upToDayKey endDayKey: Date, context: ModelContext,
math: DayMath) throws -> Counts {
var counts = Counts()
let upper = math.dayRange(forKey: endDayKey).upperBound
let farFuture = Date.distantFuture
// ( nil farFuture )
let sessions = try context.fetch(FetchDescriptor<TimeSession>(
predicate: #Predicate { ($0.endAt ?? farFuture) <= upper }
))
for session in sessions { context.delete(session) }
counts.sessions = sessions.count
let entries = try context.fetch(FetchDescriptor<CountEntry>(
predicate: #Predicate { $0.timestamp < upper }
))
for entry in entries { context.delete(entry) }
counts.entries = entries.count
// (§4.1)
let diaryEntries = try context.fetch(FetchDescriptor<DiaryEntry>(
predicate: #Predicate { $0.dayKey <= endDayKey }
))
for entry in diaryEntries { context.delete(entry) }
counts.diaryEntries = diaryEntries.count
return counts
}
// MARK:
/// (cascade: , , ·· ).
/// includeLocalPrefs=true · App Group
/// ( false defaults ).
@MainActor
static func deleteAll(context: ModelContext, includeLocalPrefs: Bool = true) throws -> Counts {
var counts = Counts()
let diaryEntries = try context.fetch(FetchDescriptor<DiaryEntry>())
for entry in diaryEntries { context.delete(entry) }
counts.diaryEntries = diaryEntries.count
let templates = try context.fetch(FetchDescriptor<DiaryTemplate>())
for template in templates { context.delete(template) }
counts.templates = templates.count
let goals = try context.fetch(FetchDescriptor<Goal>())
for goal in goals { context.delete(goal) }
counts.goals = goals.count
let actions = try context.fetch(FetchDescriptor<Action>())
for action in actions { context.delete(action) }
counts.actions = actions.count
let tags = try context.fetch(FetchDescriptor<Tag>())
for tag in tags { context.delete(tag) }
counts.tags = tags.count
// cascade ( )
let sessions = try context.fetch(FetchDescriptor<TimeSession>())
for session in sessions { context.delete(session) }
counts.sessions = sessions.count
let entries = try context.fetch(FetchDescriptor<CountEntry>())
for entry in entries { context.delete(entry) }
counts.entries = entries.count
if includeLocalPrefs {
let keys = [
LocalPrefsKeys.actionOrder, LocalPrefsKeys.pinnedGoals,
LocalPrefsKeys.collapsedGoals, LocalPrefsKeys.notePromptActions,
LocalPrefsKeys.watchGoals, LocalPrefsKeys.mainOthersCollapsed,
]
for key in keys { LocalPrefs.defaults.removeObject(forKey: key) }
}
return counts
}
// MARK: (-dataResetTest · , )
#if DEBUG
@MainActor
static func selfTestIfRequested() {
guard UserDefaults.standard.bool(forKey: "dataResetTest") 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
let math = DayMath(settings: TrackingSettings(weekStartWeekday: 2, dayStartMinutes: 0))
// : (cut) · ·· + + +
let cal = math.calendar
let today = cal.startOfDay(for: Date(timeIntervalSince1970: 1_754_000_000)) //
let cutKey = cal.date(byAdding: .day, value: -7, to: today)! // 7
let cutUpper = math.dayRange(forKey: cutKey).upperBound
let tag = Tag(name: "테스트", colorHex: "#2F6B4F")
context.insert(tag)
let action = Action(name: "검증행동", symbolName: "book.fill", trackingType: .time, sortOrder: 0)
context.insert(action)
let countAction = Action(name: "검증횟수", symbolName: "number", trackingType: .count, sortOrder: 1)
context.insert(countAction)
let goal = Goal(title: "검증목표", symbolName: "flag.fill", colorHex: "#2F6B4F",
startDate: cutUpper.addingTimeInterval(-86400 * 30), endDate: nil)
context.insert(goal)
// ( )
context.insert(TimeSession(action: action,
startAt: cutUpper.addingTimeInterval(-7200),
endAt: cutUpper.addingTimeInterval(-3600)))
// ( , )
context.insert(TimeSession(action: action,
startAt: cutUpper.addingTimeInterval(-1800),
endAt: cutUpper.addingTimeInterval(1800)))
// ()
context.insert(TimeSession(action: action,
startAt: cutUpper.addingTimeInterval(3600),
endAt: cutUpper.addingTimeInterval(7200)))
//
context.insert(TimeSession(action: action,
startAt: cutUpper.addingTimeInterval(-600), endAt: nil))
// : 2 + 1
context.insert(CountEntry(action: countAction,
timestamp: cutUpper.addingTimeInterval(-100), amount: 1))
context.insert(CountEntry(action: countAction,
timestamp: cutUpper.addingTimeInterval(-200), amount: 2))
context.insert(CountEntry(action: countAction,
timestamp: cutUpper.addingTimeInterval(100), amount: 3))
// : ()· = , = . cascade 1
let diaryOld = DiaryEntry(dayKey: cal.date(byAdding: .day, value: -1, to: cutKey)!)
context.insert(diaryOld)
let page = DiaryPage(index: 1)
page.entry = diaryOld
context.insert(page)
let diaryCut = DiaryEntry(dayKey: cutKey)
context.insert(diaryCut)
let diaryKeep = DiaryEntry(dayKey: cal.date(byAdding: .day, value: 1, to: cutKey)!)
context.insert(diaryKeep)
let template = DiaryTemplate(name: "양식", kind: .image)
context.insert(template)
try context.save()
// ---- ----
let rangeCounts = try DataReset.deleteRecords(upToDayKey: cutKey, context: context, math: math)
try context.save()
expect("범위: 삭제된 세션 수(완전 포함만)", rangeCounts.sessions, 1)
expect("범위: 삭제된 횟수 수", rangeCounts.entries, 2)
expect("범위: 삭제된 일기 수(기준일 포함)", rangeCounts.diaryEntries, 2)
expect("범위: 남은 세션(걸침+밖+진행 중)",
(try? context.fetchCount(FetchDescriptor<TimeSession>())) ?? -1, 3)
expect("범위: 남은 횟수", (try? context.fetchCount(FetchDescriptor<CountEntry>())) ?? -1, 1)
expect("범위: 남은 일기", (try? context.fetchCount(FetchDescriptor<DiaryEntry>())) ?? -1, 1)
expect("범위: 일기 페이지 cascade", (try? context.fetchCount(FetchDescriptor<DiaryPage>())) ?? -1, 0)
expect("범위: 행동 보존", (try? context.fetchCount(FetchDescriptor<Action>())) ?? -1, 2)
expect("범위: 꼬리표 보존", (try? context.fetchCount(FetchDescriptor<Tag>())) ?? -1, 1)
expect("범위: 목표 보존", (try? context.fetchCount(FetchDescriptor<Goal>())) ?? -1, 1)
expect("범위: 양식 보존", (try? context.fetchCount(FetchDescriptor<DiaryTemplate>())) ?? -1, 1)
// ---- ( LocalPrefs ) ----
_ = try DataReset.deleteAll(context: context, includeLocalPrefs: false)
try context.save()
expect("전체: 세션 0", (try? context.fetchCount(FetchDescriptor<TimeSession>())) ?? -1, 0)
expect("전체: 횟수 0", (try? context.fetchCount(FetchDescriptor<CountEntry>())) ?? -1, 0)
expect("전체: 행동 0", (try? context.fetchCount(FetchDescriptor<Action>())) ?? -1, 0)
expect("전체: 꼬리표 0", (try? context.fetchCount(FetchDescriptor<Tag>())) ?? -1, 0)
expect("전체: 목표 0", (try? context.fetchCount(FetchDescriptor<Goal>())) ?? -1, 0)
expect("전체: 일기 0", (try? context.fetchCount(FetchDescriptor<DiaryEntry>())) ?? -1, 0)
expect("전체: 양식 0", (try? context.fetchCount(FetchDescriptor<DiaryTemplate>())) ?? -1, 0)
} catch {
failures += 1
lines.append("FAIL 예외: \(error)")
}
lines.append(failures == 0 ? "== ALL PASS ==" : "== \(failures) FAILURES ==")
let url = URL.documentsDirectory.appending(path: "data-reset-test.txt")
try? lines.joined(separator: "\n").write(to: url, atomically: true, encoding: .utf8)
}
#endif
}