Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 94 additions & 7 deletions Paxo/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ final class AppState: ObservableObject {
@Published private(set) var phase: SolvePhase = .idle
@Published private(set) var current: SolveResult?
@Published private(set) var history: [SolveResult] = []
@Published private(set) var subjectCatalog = SubjectCatalog()
@Published private(set) var subjectCatalogReady = false
@Published private(set) var subjectError: String?
/// 지금 기다리는 요청이 시작된 시각. 대기 화면이 경과 시간으로 안내 단계를 고른다.
@Published private(set) var waitingSince: Date?

Expand Down Expand Up @@ -58,11 +61,13 @@ final class AppState: ObservableObject {
}

var recentHistory: [SolveResult] {
Array(history.prefix(min(max(recentHistoryLimit, 1), 100)))
HistoryBrowser.recent(history, tier: serverTier, requestedCount: recentHistoryLimit)
}

@Published var preset: SubjectPreset {
didSet { UserDefaults.standard.set(preset.rawValue, forKey: "preset") }
var historyDisplayLimit: Int { serverTier == .pro ? 100 : 10 }

var visibleHistory: [SolveResult] {
HistoryBrowser.visible(history, tier: serverTier)
}
@Published var captureMode: CaptureMode {
didSet { UserDefaults.standard.set(captureMode.rawValue, forKey: "captureMode") }
Expand Down Expand Up @@ -162,11 +167,13 @@ final class AppState: ObservableObject {
private let paywallWindow = PaywallWindowController()
private let onboardingWindow = OnboardingWindowController()
private let historyStore = HistoryStore()
private let subjectStore = SubjectStore()
private let sessionManager = ProxySessionManager()
/// 메모리 사용을 제한하고 오래된 문제 이미지는 필요할 때 로컬 기록에서 다시 읽는다.
private var imageCache: [UUID: Data] = [:]
private var thumbnailCache: [UUID: NSImage] = [:]
private var imageCacheOrder: [UUID] = []
private var activeHistoryResultID: UUID?
private let requestGate = SolveRequestGate()
private var activeTask: Task<Void, Never>?
/// 해설 요청이 날아가 있는 풀이와 그 시작 시각. 서버는 같은 해설을 두 번 만들지 않으므로
Expand All @@ -184,7 +191,6 @@ final class AppState: ObservableObject {
showsRecentHistory = UserDefaults.standard.object(forKey: "showsRecentHistory") as? Bool ?? true
historyShowsExplanation = UserDefaults.standard.object(forKey: "historyShowsExplanation") as? Bool ?? true
recentHistoryLimit = min(max(UserDefaults.standard.object(forKey: "recentHistoryLimit") as? Int ?? 5, 1), 100)
preset = SubjectPreset(rawValue: UserDefaults.standard.string(forKey: "preset") ?? "") ?? .general
captureMode = CaptureMode(rawValue: UserDefaults.standard.string(forKey: "captureMode") ?? "") ?? .region
panelPosition =
PanelPosition(rawValue: UserDefaults.standard.string(forKey: "panelPosition") ?? "") ?? .topRight
Expand All @@ -211,6 +217,12 @@ final class AppState: ObservableObject {

func start() {
history = historyStore.load()
do {
subjectCatalog = try subjectStore.load(migrating: history)
subjectCatalogReady = true
} catch {
subjectError = "과목 정보를 불러오지 못했습니다. 앱을 다시 실행해주세요."
}
#if DEBUG
sessionManager.developmentProxyURL = proxyURL
#endif
Expand Down Expand Up @@ -320,14 +332,15 @@ final class AppState: ObservableObject {
/// 패널 안의 홈에서 기록을 열 때는 현재 창을 닫지 않고 상세 화면으로 전환한다.
@discardableResult
func showFromHistory(_ item: SolveResult, presentPanel: Bool = true, dismissPanel: Bool = true) -> Bool {
guard !isSolving else { return false }
guard !isSolving, visibleHistory.contains(where: { $0.id == item.id }) else { return false }
toastDismissTask?.cancel()
// 페이드하는 동안 토스트 내용이 방금 연 기록의 답으로 바뀌어 보이지 않게 바로 닫는다
toast.dismiss(animated: false)
if imageCache[item.id] == nil, let image = historyStore.loadImage(for: item), NSImage(data: image) != nil {
cacheImage(image, for: item.id)
}
current = item
activeHistoryResultID = item.id
phase = item.explanation == nil ? .answerReady : .done
if presentPanel {
resultPanel.show(appState: self, on: Self.screenUnderMouse())
Expand All @@ -349,6 +362,53 @@ final class AppState: ObservableObject {
return NSImage(data: data)
}

var historySubjects: [HistorySubject] {
serverTier == .pro && subjectCatalogReady ? subjectCatalog.subjects : []
}

func subject(for resultID: UUID) -> HistorySubject? {
guard serverTier == .pro, subjectCatalogReady else { return nil }
return subjectCatalog.subject(for: resultID)
}

@discardableResult
func createSubject(_ name: String) -> Bool {
updateSubjects { try $0.create(name) }
}

@discardableResult
func renameSubject(_ id: UUID, to name: String) -> Bool {
updateSubjects { try $0.rename(id, to: name) }
}

@discardableResult
func deleteSubject(_ id: UUID) -> Bool {
updateSubjects { try $0.delete(id) }
}

@discardableResult
func assignSubject(_ subjectID: UUID?, to resultID: UUID) -> Bool {
guard history.contains(where: { $0.id == resultID }) else { return false }
return updateSubjects { try $0.assign(subjectID, to: resultID) }
}

func clearSubjectError() { subjectError = nil }

private func updateSubjects(_ change: (inout SubjectCatalog) throws -> Void) -> Bool {
guard serverTier == .pro, subjectCatalogReady else { return false }
do {
var updated = subjectCatalog
try change(&updated)
try subjectStore.save(updated)
subjectCatalog = updated
subjectError = nil
return true
} catch {
subjectError = (error as? LocalizedError)?.errorDescription ?? "과목 정보를 저장하지 못했습니다."
return false
}
}

func updateCorrectness(_ correctness: SolveCorrectness) {
guard let result = current, result.answer != nil else { return }
current?.correctness = result.correctness == correctness ? nil : correctness
Expand Down Expand Up @@ -519,19 +579,33 @@ final class AppState: ObservableObject {
authenticationPhase = .signedOut
remainingToday = 0
serverTier = .free
enforceHistoryAccess()
usageResetAt = nil
}

private func apply(_ session: ProxySession) {
remainingToday = session.remainingToday
serverTier = session.tier
enforceHistoryAccess()
usageResetAt = session.resetAt
}

private func apply(_ result: GenerationResult) {
if let remaining = result.remainingToday { remainingToday = remaining }
if let resetAt = result.resetAt { usageResetAt = resetAt }
if let tier = result.tier { serverTier = tier }
if let tier = result.tier {
serverTier = tier
enforceHistoryAccess()
}
}

private func enforceHistoryAccess() {
guard HistoryBrowser.shouldClose(activeResultID: activeHistoryResultID, items: history, tier: serverTier)
else { return }
activeHistoryResultID = nil
current = nil
phase = .idle
resultPanel.hide()
}

private func prepareAndRunSolve() async {
Expand Down Expand Up @@ -598,7 +672,8 @@ final class AppState: ObservableObject {
}

lastCaptureScreen = capture.screen
let result = SolveResult(preset: preset)
let result = SolveResult(preset: .general)
activeHistoryResultID = nil
cacheImage(capture.data, for: result.id)
current = result
waitingSince = Date()
Expand Down Expand Up @@ -856,6 +931,18 @@ final class AppState: ObservableObject {
history = Array(history.prefix(100))
}
historyStore.save(history)
if subjectCatalogReady {
var updated = subjectCatalog
updated.prune(keeping: Set(history.map(\.id)))
if updated != subjectCatalog {
do {
try subjectStore.save(updated)
subjectCatalog = updated
} catch {
subjectError = "과목 정보를 저장하지 못했습니다."
}
}
}
}

private func errorMessage(from error: Error) -> String {
Expand Down
2 changes: 1 addition & 1 deletion Paxo/Models.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ enum ResultFontSize: String, CaseIterable, Codable, Identifiable {
}
}

/// 과목별 풀이 안내와 프롬프트 선택을 하나의 설정값으로 유지한다.
/// 예전 풀이 기록을 복원하고 생성 당시의 프롬프트 구분을 유지한다.
enum SubjectPreset: String, CaseIterable, Codable, Identifiable {
case general
case math
Expand Down
37 changes: 35 additions & 2 deletions Paxo/Storage/HistoryBrowser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,27 @@ import Foundation

/// 날짜 경계는 기기의 달력을 사용해 자정과 시간대 변경에도 기록을 정확히 묶는다.
enum HistoryBrowser {
static func visible(_ items: [SolveResult], tier: ProxyTier) -> [SolveResult] {
Array(items.sorted { $0.date > $1.date }.prefix(tier == .pro ? 100 : 10))
}

static func recent(_ items: [SolveResult], tier: ProxyTier, requestedCount: Int) -> [SolveResult] {
Array(visible(items, tier: tier).prefix(max(requestedCount, 1)))
}

static func shouldClose(activeResultID: UUID?, items: [SolveResult], tier: ProxyTier) -> Bool {
guard let activeResultID else { return false }
return !visible(items, tier: tier).contains { $0.id == activeResultID }
}

static func filter(
_ items: [SolveResult], query: String, date: Date? = nil, calendar: Calendar = .current
_ items: [SolveResult], query: String, date: Date? = nil,
subjectName: (UUID) -> String? = { _ in nil }, calendar: Calendar = .current
) -> [SolveResult] {
let terms = query.split(whereSeparator: { $0.isWhitespace }).map(String.init)
return items.filter { item in
if let date, !calendar.isDate(item.date, inSameDayAs: date) { return false }
let text = [item.answer ?? "", item.explanation ?? "", item.preset.displayName].joined(separator: " ")
let text = [item.answer ?? "", item.explanation ?? "", subjectName(item.id) ?? ""].joined(separator: " ")
return terms.allSatisfy {
text.range(of: $0, options: [.caseInsensitive, .diacriticInsensitive]) != nil
}
Expand All @@ -20,10 +34,29 @@ enum HistoryBrowser {
.map { HistoryDay(date: $0.key, items: $0.value.sorted { $0.date > $1.date }) }
.sorted { $0.date > $1.date }
}

static func subjects(_ items: [SolveResult], catalog: SubjectCatalog) -> [HistorySubjectGroup] {
let grouped = Dictionary(grouping: items) { catalog.subject(for: $0.id)?.id }
let assigned = catalog.subjects
.sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending }
.compactMap { subject -> HistorySubjectGroup? in
guard let results = grouped[subject.id], !results.isEmpty else { return nil }
return HistorySubjectGroup(subjectID: subject.id, name: subject.name, items: results)
}
guard let unclassified = grouped[nil], !unclassified.isEmpty else { return assigned }
return assigned + [HistorySubjectGroup(subjectID: nil, name: "미분류", items: unclassified)]
}
}

struct HistoryDay: Identifiable {
let date: Date
let items: [SolveResult]
var id: Date { date }
}

struct HistorySubjectGroup: Identifiable {
let subjectID: UUID?
let name: String
let items: [SolveResult]
var id: String { subjectID?.uuidString ?? "unclassified" }
}
128 changes: 128 additions & 0 deletions Paxo/Storage/SubjectStore.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import Foundation

/// 기록의 과목 이름과 지정 관계를 풀이 본문과 별도로 보관한다.
struct HistorySubject: Identifiable, Codable, Equatable {
let id: UUID
var name: String

init(id: UUID = UUID(), name: String) {
self.id = id
self.name = name
}
}

struct SubjectCatalog: Codable, Equatable {
private(set) var subjects: [HistorySubject] = []
private(set) var assignments: [String: UUID] = [:]
private(set) var didMigrateLegacyPresets = false

static func migrated(from history: [SolveResult]) -> SubjectCatalog {
var catalog = SubjectCatalog()
for preset in [SubjectPreset.math, .licenseExam] {
let items = history.filter { $0.preset == preset }
guard !items.isEmpty else { continue }
let subject = HistorySubject(name: preset.displayName)
catalog.subjects.append(subject)
for item in items {
catalog.assignments[item.id.uuidString] = subject.id
}
}
catalog.didMigrateLegacyPresets = true
return catalog
}

func subject(for resultID: UUID) -> HistorySubject? {
guard let id = assignments[resultID.uuidString] else { return nil }
return subjects.first { $0.id == id }
}

mutating func create(_ name: String) throws {
let name = try validatedName(name)
subjects.append(HistorySubject(name: name))
}

mutating func rename(_ id: UUID, to name: String) throws {
guard let index = subjects.firstIndex(where: { $0.id == id }) else {
throw SubjectError.notFound
}
subjects[index].name = try validatedName(name, excluding: id)
}

mutating func delete(_ id: UUID) throws {
guard subjects.contains(where: { $0.id == id }) else { throw SubjectError.notFound }
subjects.removeAll { $0.id == id }
assignments = assignments.filter { $0.value != id }
}

mutating func assign(_ subjectID: UUID?, to resultID: UUID) throws {
if let subjectID {
guard subjects.contains(where: { $0.id == subjectID }) else { throw SubjectError.notFound }
assignments[resultID.uuidString] = subjectID
} else {
assignments.removeValue(forKey: resultID.uuidString)
}
}

mutating func prune(keeping resultIDs: Set<UUID>) {
let keys = Set(resultIDs.map(\.uuidString))
assignments = assignments.filter { keys.contains($0.key) }
}

private func validatedName(_ raw: String, excluding id: UUID? = nil) throws -> String {
let name = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !name.isEmpty, name.count <= 30, name != "미분류" else {
throw SubjectError.invalidName
}
guard
!subjects.contains(where: {
$0.id != id
&& $0.name.compare(name, options: [.caseInsensitive, .diacriticInsensitive]) == .orderedSame
})
else { throw SubjectError.duplicateName }
return name
}
}

struct SubjectStore {
private let fileURL: URL
private let fileManager: FileManager

init(baseURL: URL? = nil, fileManager: FileManager = .default) {
self.fileManager = fileManager
let directory =
baseURL
?? fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
.appendingPathComponent("Paxo", isDirectory: true)
fileURL = directory.appendingPathComponent("subjects.json")
}

func load(migrating history: [SolveResult]) throws -> SubjectCatalog {
if fileManager.fileExists(atPath: fileURL.path) {
return try JSONDecoder().decode(SubjectCatalog.self, from: Data(contentsOf: fileURL))
}
let catalog = SubjectCatalog.migrated(from: history)
try save(catalog)
return catalog
}

func save(_ catalog: SubjectCatalog) throws {
try fileManager.createDirectory(
at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true
)
try JSONEncoder().encode(catalog).write(to: fileURL, options: .atomic)
}
}

enum SubjectError: LocalizedError {
case invalidName
case duplicateName
case notFound

var errorDescription: String? {
switch self {
case .invalidName: "과목 이름은 1~30자로 입력하고 '미분류'는 사용할 수 없습니다."
case .duplicateName: "이미 같은 이름의 과목이 있습니다."
case .notFound: "과목을 찾을 수 없습니다. 다시 선택해주세요."
}
}
}
Loading
Loading