mycode/myApp/HaruDanim/Shared/DataStore.swift
songyc macbook bdb97e3c9f feat(diary): imported page templates — PDF/photo backgrounds with per-page picker
일기 노트 페이지 양식(속지) 기능 (합의된 설계 그대로):

- 모델: DiaryTemplate 신설(원본 externalStorage — 여러 쪽 PDF도
  통째 1개만 저장 + pageCount) + DiaryPage.templateID/
  templatePageIndex(기본값 있는 새 필드, uuid 문자열 참조 —
  CloudKit 규칙·동기화 순서에 안전). 스키마에 등록.
- 양식 관리: 달력 툴바에서 진입 — PDF(파일, ≤20MB)·사진 가져오기,
  이름 변경·삭제(사용 중 페이지 수 경고 → 빈 캔버스 폴백, 필기 보존),
  첫 쪽 썸네일 저장. 쪽 상한 24쪽(초과분 안내).
- 페이지 추가: 양식이 있으면 [빈 캔버스/줄 노트/내 양식] 선택 시트,
  여러 쪽 PDF는 2단계 쪽 썸네일 선택(지연 생성). 양식이 없으면
  기존처럼 즉시 추가 — 흔한 경로의 반응성 유지.
- 렌더: 페이지 논리 좌표(768×1024)에 aspect-fit — 필기 좌표계 불변.
  PDF는 벡터 원본을 보존하고 줌 종료 시 현재 배율로 백그라운드
  재래스터(펜슬 캔버스 선명도 훅에 함께 연결 — 확대해도 흐림 없음).
  이미지는 가져올 때 긴 변 3072px 리샘플(필기 래스터 상한 3×와 동일).
  양식 페이지는 줄 노트 토글 숨김(상호 배타), 양식만 깔린 페이지도
  작성함 취급(hasContent). PencilKit 캔버스 코드는 건드리지 않음.
- 내보내기(PDF/PNG)에도 양식 배경 동일 반영.

검증(iPad 시뮬레이터, -diarySeedTemplate 등 신규 DEBUG 인자):
양식 관리·페이지 선택 시트·양식 페이지 렌더(2× 프로그램 줌에서
점 노트 선명) 스크린샷 + 이미지 내보내기 산출물에 양식 포함 확인.
Debug·Store 빌드 성공, 신규 문자열 22종 ko/en/ja(missing 0).
도움말 '나만의 양식(속지)' 항목 추가, CLAUDE.md §3·§6.7·§14 갱신.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7
2026-07-14 19:10:50 +09:00

200 lines
9.2 KiB
Swift

//
// DataStore.swift
// Haru_Danim
//
// SwiftData (CLAUDE.md §2.2)
// - : App Group (·App Intents DB )
// - default.store 1 App Group
// - iCloud (): CloudKit ,
// - CloudKit . (.appex)
// CloudKit ·
// / . CloudKit .
//
import Foundation
import SwiftData
nonisolated enum DataStore {
/// .
/// App Intents .
static let shared: ModelContainer = makeContainer()
static let schema = Schema([
Tag.self,
Action.self,
TimeSession.self,
CountEntry.self,
Goal.self,
Quest.self,
DiaryEntry.self,
DiaryTodo.self,
DiaryPage.self,
DiaryPageItem.self,
DiaryTemplate.self,
])
/// iCloud on/off (, App Group defaults )
static let cloudSyncKey = "settings.cloudSync"
static let cloudContainerID = "iCloud.com.yechan.HaruDanim"
/// App Group
static var storeURL: URL {
guard let base = FileManager.default.containerURL(
forSecurityApplicationGroupIdentifier: AppGroup.identifier
) else {
// App Group ( )
return URL.applicationSupportDirectory.appending(path: "default.store")
}
return base.appending(path: "HaruDanim.store")
}
/// (.appex)
static var isExtensionProcess: Bool {
Bundle.main.bundleURL.pathExtension == "appex"
}
/// iCloud ( + on)
static var wantsCloudSync: Bool {
#if DEBUG
if UserDefaults.standard.object(forKey: "cloudSync") != nil {
return UserDefaults.standard.bool(forKey: "cloudSync")
}
#endif
return PremiumGate.isUnlocked(.cloudSync) && AppGroup.defaults.bool(forKey: cloudSyncKey)
}
/// ·· .
/// CloudKit .
///
/// :
/// 1) (
/// ) ,
/// 2) ( / )
/// fatalError
/// . (HaruDanim.store.backup-*) .
static func makeContainer() -> ModelContainer {
do {
return try makeContainerThrowing()
} catch {
print("[DataStore] 컨테이너 생성 실패, 재시도: \(error)")
for attempt in 1...3 {
Thread.sleep(forTimeInterval: 0.4 * Double(attempt))
if let container = try? makeContainerThrowing() {
return container
}
}
print("[DataStore] 재시도 실패 — 스토어를 백업하고 새로 시작: \(error)")
backupBrokenStore()
do {
return try makeContainerThrowing()
} catch {
fatalError("[DataStore] 컨테이너 생성 실패(백업 후 재생성도 실패): \(error)")
}
}
}
/// (-wal/-shm ) .
/// .
private static func backupBrokenStore() {
let fileManager = FileManager.default
let stamp = Int(Date.now.timeIntervalSince1970)
for suffix in ["", "-wal", "-shm"] {
let source = URL(fileURLWithPath: storeURL.path + suffix)
guard fileManager.fileExists(atPath: source.path) else { continue }
let destination = URL(fileURLWithPath: storeURL.path + ".backup-\(stamp)" + suffix)
try? fileManager.moveItem(at: source, to: destination)
}
}
/// makeContainer throwing .
/// try? ,
/// (= ''
/// ) .
static func makeContainerThrowing() throws -> ModelContainer {
migrateLegacyStoreIfNeeded()
if wantsCloudSync && !isExtensionProcess {
do {
let config = ModelConfiguration(
schema: schema,
url: storeURL,
cloudKitDatabase: .private(cloudContainerID)
)
return try ModelContainer(for: schema, configurations: [config])
} catch {
// iCloud /
print("[DataStore] CloudKit 컨테이너 생성 실패, 로컬로 폴백: \(error)")
}
}
let config = ModelConfiguration(schema: schema, url: storeURL, cloudKitDatabase: .none)
return try ModelContainer(for: schema, configurations: [config])
}
/// uuid : UUID
/// ( · , 1 )
@MainActor
static func ensureUniqueEntityIDs(context: ModelContext) {
var changed = false
var tagIDs = Set<UUID>()
for tag in (try? context.fetch(FetchDescriptor<Tag>())) ?? [] where !tagIDs.insert(tag.uuid).inserted {
tag.uuid = UUID()
changed = true
}
var actionIDs = Set<UUID>()
for action in (try? context.fetch(FetchDescriptor<Action>())) ?? [] where !actionIDs.insert(action.uuid).inserted {
action.uuid = UUID()
changed = true
}
var goalIDs = Set<UUID>()
for goal in (try? context.fetch(FetchDescriptor<Goal>())) ?? [] where !goalIDs.insert(goal.uuid).inserted {
goal.uuid = UUID()
changed = true
}
var questIDs = Set<UUID>()
for quest in (try? context.fetch(FetchDescriptor<Quest>())) ?? [] where !questIDs.insert(quest.uuid).inserted {
quest.uuid = UUID()
changed = true
}
if changed { try? context.save() }
}
/// (Application Support/default.store) DB App Group 1
private static func migrateLegacyStoreIfNeeded() {
let fm = FileManager.default
let target = storeURL
guard target.path != URL.applicationSupportDirectory.appending(path: "default.store").path,
!fm.fileExists(atPath: target.path) else { return }
let legacy = URL.applicationSupportDirectory.appending(path: "default.store")
guard fm.fileExists(atPath: legacy.path) else { return }
// -wal/-shm
for suffix in ["", "-shm", "-wal"] {
let from = URL(fileURLWithPath: legacy.path + suffix)
let to = URL(fileURLWithPath: target.path + suffix)
if fm.fileExists(atPath: from.path) {
try? fm.copyItem(at: from, to: to)
}
}
}
}
// MARK: - App Group
/// · ( , , )
/// UserDefaults.standard App Group defaults 1
nonisolated enum SettingsMigration {
private static let doneKey = "migration.settingsToAppGroup"
static func runIfNeeded() {
let group = AppGroup.defaults
guard !group.bool(forKey: doneKey) else { return }
let standard = UserDefaults.standard
for key in [SettingsKeys.weekStartWeekday, SettingsKeys.dayStartMinutes,
SettingsKeys.liveActivityMode, SettingsKeys.minSessionSeconds,
SettingsKeys.theme] {
if group.object(forKey: key) == nil, let value = standard.object(forKey: key) {
group.set(value, forKey: key)
}
}
group.set(true, forKey: doneKey)
}
}