From ad5367826d75ebfe446e6e0a2e227708f59cb5d5 Mon Sep 17 00:00:00 2001 From: opficdev Date: Sun, 29 Mar 2026 23:59:14 +0900 Subject: [PATCH 01/29] =?UTF-8?q?feat:=20=EA=B8=B0=EC=A1=B4=20TodoCategory?= =?UTF-8?q?=EB=A5=BC=20SystemTodoCategory=EB=A1=9C=20=EC=88=98=EC=A0=95,?= =?UTF-8?q?=20UserTodoCategory=EB=A5=BC=20=EC=83=9D=EC=84=B1=ED=95=98?= =?UTF-8?q?=EA=B3=A0=20=EA=B8=B0=EC=A1=B4=20=EC=B9=B4=ED=85=8C=EC=BD=94?= =?UTF-8?q?=EB=A6=AC=EB=8A=94=20=EB=91=90=20=EC=A2=85=EB=A5=98=EB=A5=BC=20?= =?UTF-8?q?=EC=95=84=EC=9A=B0=EB=A5=B4=EB=8A=94=20=ED=98=95=ED=83=9C?= =?UTF-8?q?=EB=A1=9C=20=EA=B5=AC=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DevLog/Data/DTO/TodoDTO.swift | 2 +- .../Data/Mapper/PushNotificationMapping.swift | 4 +- DevLog/Data/Mapper/TodoMapping.swift | 6 +- DevLog/Domain/Entity/SystemTodoCategory.swift | 19 ++++++ DevLog/Domain/Entity/Todo.swift | 2 +- DevLog/Domain/Entity/TodoCategory.swift | 22 +++++++ DevLog/Domain/Entity/TodoReferenceItem.swift | 2 +- DevLog/Domain/Entity/UserTodoCategory.swift | 14 +++++ DevLog/Infra/Service/TodoService.swift | 8 +-- .../SystemTodoCategory+Presentation.swift} | 43 +++++++------ .../Extension/TodoCategory+Presentation.swift | 61 +++++++++++++++++++ .../UserTodoCategory+Presentation.swift | 28 +++++++++ .../Profile/ProfileSelectedDayActivity.swift | 12 ++++ .../ViewModel/HomeViewModel.swift | 4 +- .../ViewModel/TodoEditorViewModel.swift | 2 +- DevLog/UI/Home/TodoEditorView.swift | 4 +- 16 files changed, 194 insertions(+), 39 deletions(-) create mode 100644 DevLog/Domain/Entity/SystemTodoCategory.swift create mode 100644 DevLog/Domain/Entity/TodoCategory.swift create mode 100644 DevLog/Domain/Entity/UserTodoCategory.swift rename DevLog/Presentation/{Enum/TodoCategory.swift => Extension/SystemTodoCategory+Presentation.swift} (65%) create mode 100644 DevLog/Presentation/Extension/TodoCategory+Presentation.swift create mode 100644 DevLog/Presentation/Extension/UserTodoCategory+Presentation.swift diff --git a/DevLog/Data/DTO/TodoDTO.swift b/DevLog/Data/DTO/TodoDTO.swift index 723f58a9..0ed3b772 100644 --- a/DevLog/Data/DTO/TodoDTO.swift +++ b/DevLog/Data/DTO/TodoDTO.swift @@ -20,7 +20,7 @@ struct TodoRequest: Encodable { let completedAt: Date? let dueDate: Date? let tags: [String] - let category: TodoCategory + let category: String } struct TodoResponse { diff --git a/DevLog/Data/Mapper/PushNotificationMapping.swift b/DevLog/Data/Mapper/PushNotificationMapping.swift index d0bcc7c8..7861954d 100644 --- a/DevLog/Data/Mapper/PushNotificationMapping.swift +++ b/DevLog/Data/Mapper/PushNotificationMapping.swift @@ -7,7 +7,7 @@ extension PushNotificationResponse { func toDomain() throws -> PushNotification { - guard let todoCategory = TodoCategory(rawValue: self.todoCategory) else { + guard let todoCategory = SystemTodoCategory(rawValue: self.todoCategory) else { throw DataError.invalidData("PushNotificationResponse.todoCategory is invalid: \(self.todoCategory)") } @@ -18,7 +18,7 @@ extension PushNotificationResponse { receivedAt: self.receivedAt, isRead: self.isRead, todoId: self.todoId, - todoCategory: todoCategory + todoCategory: .system(todoCategory) ) } } diff --git a/DevLog/Data/Mapper/TodoMapping.swift b/DevLog/Data/Mapper/TodoMapping.swift index 5a4b9790..d810d90c 100644 --- a/DevLog/Data/Mapper/TodoMapping.swift +++ b/DevLog/Data/Mapper/TodoMapping.swift @@ -20,14 +20,14 @@ extension TodoRequest { completedAt: entity.completedAt, dueDate: entity.dueDate, tags: entity.tags, - category: entity.category + category: entity.category.storageValue ) } } extension TodoResponse { func toDomain() throws -> Todo { - guard let category = TodoCategory(rawValue: self.category) else { + guard let category = SystemTodoCategory(rawValue: self.category) else { throw DataError.invalidData("TodoResponse.category is invalid: \(self.category)") } @@ -44,7 +44,7 @@ extension TodoResponse { completedAt: self.completedAt, dueDate: self.dueDate, tags: self.tags, - category: category + category: .system(category) ) } } diff --git a/DevLog/Domain/Entity/SystemTodoCategory.swift b/DevLog/Domain/Entity/SystemTodoCategory.swift new file mode 100644 index 00000000..e49fdd52 --- /dev/null +++ b/DevLog/Domain/Entity/SystemTodoCategory.swift @@ -0,0 +1,19 @@ +// +// SystemTodoCategory.swift +// DevLog +// +// Created by opfic on 3/29/26. +// + +import Foundation + +enum SystemTodoCategory: String, CaseIterable { + case issue // 이슈 + case feature // 신규 기능 + case improvement // 개선/리팩터링 + case review // 코드/문서 리뷰 + case test // 테스트/QA + case doc // 문서화 + case research // 리서치/학습 + case etc // 기타 +} diff --git a/DevLog/Domain/Entity/Todo.swift b/DevLog/Domain/Entity/Todo.swift index 00f59d7a..e34da02c 100644 --- a/DevLog/Domain/Entity/Todo.swift +++ b/DevLog/Domain/Entity/Todo.swift @@ -7,7 +7,7 @@ import Foundation -struct Todo: Identifiable, Hashable { +struct Todo: Equatable { let id: String var isPinned: Bool // 해당 할 일이 상단에 고정되어 있는지 여부 var isCompleted: Bool // 해당 할 일의 완료 여부 diff --git a/DevLog/Domain/Entity/TodoCategory.swift b/DevLog/Domain/Entity/TodoCategory.swift new file mode 100644 index 00000000..d4c97d2b --- /dev/null +++ b/DevLog/Domain/Entity/TodoCategory.swift @@ -0,0 +1,22 @@ +// +// TodoCategory.swift +// DevLog +// +// Created by opfic on 3/29/26. +// + +import Foundation + +enum TodoCategory: Equatable { + case system(SystemTodoCategory) + case user(UserTodoCategory) + + var storageValue: String { + switch self { + case .system(let category): + return category.rawValue + case .user(let category): + return category.category + } + } +} diff --git a/DevLog/Domain/Entity/TodoReferenceItem.swift b/DevLog/Domain/Entity/TodoReferenceItem.swift index d3cc572d..8747b412 100644 --- a/DevLog/Domain/Entity/TodoReferenceItem.swift +++ b/DevLog/Domain/Entity/TodoReferenceItem.swift @@ -7,7 +7,7 @@ import Foundation -struct TodoReferenceItem: Identifiable, Equatable { +struct TodoReferenceItem: Equatable { let id: String let title: String let category: TodoCategory diff --git a/DevLog/Domain/Entity/UserTodoCategory.swift b/DevLog/Domain/Entity/UserTodoCategory.swift new file mode 100644 index 00000000..7fb679f9 --- /dev/null +++ b/DevLog/Domain/Entity/UserTodoCategory.swift @@ -0,0 +1,14 @@ +// +// UserTodoCategory.swift +// DevLog +// +// Created by opfic on 3/29/26. +// + +import Foundation + +struct UserTodoCategory: Equatable { + let category: String + var name: String + var colorHex: String +} diff --git a/DevLog/Infra/Service/TodoService.swift b/DevLog/Infra/Service/TodoService.swift index c077e849..a576bc63 100644 --- a/DevLog/Infra/Service/TodoService.swift +++ b/DevLog/Infra/Service/TodoService.swift @@ -32,7 +32,7 @@ final class TodoService { "sortTarget=\(query.sortTarget.fieldName)", "sortOrder=\(query.sortOrder == .latest ? "latest" : "oldest")", query.keyword != nil ? "keywordLength=\(trimmedKeyword.count)" : nil, - query.category != nil ? "category=\(query.category!.rawValue)" : nil, + query.category != nil ? "category=\(query.category!.storageValue)" : nil, query.isPinned != nil ? "pinned=\(query.isPinned!)" : nil, query.completionFilter.isCompletedValue != nil ? "completed=\(query.completionFilter.isCompletedValue!)" : nil, query.dueDateFilter != .all ? "dueDateFilter=\(query.dueDateFilter)" : nil, @@ -49,7 +49,7 @@ final class TodoService { if let category = query.category { firestoreQuery = firestoreQuery.whereField( TodoFieldKey.category.rawValue, - isEqualTo: category.rawValue + isEqualTo: category.storageValue ) } @@ -270,7 +270,7 @@ final class TodoService { guard !(data[TodoFieldKey.deletingAt.rawValue] is Timestamp), let response = makeResponse(from: document), - let category = TodoCategory(rawValue: response.category) + let category = SystemTodoCategory(rawValue: response.category) else { return } @@ -278,7 +278,7 @@ final class TodoService { partialResult[response.number] = TodoReferenceItem( id: response.id, title: response.title, - category: category + category: .system(category) ) } } diff --git a/DevLog/Presentation/Enum/TodoCategory.swift b/DevLog/Presentation/Extension/SystemTodoCategory+Presentation.swift similarity index 65% rename from DevLog/Presentation/Enum/TodoCategory.swift rename to DevLog/Presentation/Extension/SystemTodoCategory+Presentation.swift index 50ddb8d3..7320f2be 100644 --- a/DevLog/Presentation/Enum/TodoCategory.swift +++ b/DevLog/Presentation/Extension/SystemTodoCategory+Presentation.swift @@ -1,24 +1,23 @@ // -// TodoCategory.swift +// SystemTodoCategory+Presentation.swift // DevLog // -// Created by opfic on 5/29/25. +// Created by opfic on 3/29/26. // import SwiftUI -enum TodoCategory: String, Identifiable, CaseIterable, Codable { - case issue // 이슈 - case feature // 신규 기능 - case improvement // 개선/리팩터링 - case review // 코드/문서 리뷰 - case test // 테스트/QA - case doc // 문서화 - case research // 리서치/학습 - case etc // 기타 - +extension SystemTodoCategory: Identifiable { var id: String { rawValue } +} +extension SystemTodoCategory: Hashable { + func hash(into hasher: inout Hasher) { + hasher.combine(rawValue) + } +} + +extension SystemTodoCategory { var symbolName: String { switch self { case .issue: return "exclamationmark.triangle" @@ -31,7 +30,7 @@ enum TodoCategory: String, Identifiable, CaseIterable, Codable { case .etc: return "ellipsis" } } - + var localizedName: String { switch self { case .issue: return NSLocalizedString("todo_category_issue", comment: "Todo category: Issue") @@ -44,17 +43,17 @@ enum TodoCategory: String, Identifiable, CaseIterable, Codable { case .etc: return NSLocalizedString("todo_category_etc", comment: "Todo category: Etc") } } - + var color: Color { switch self { - case .issue: return Color.red - case .feature: return Color.green - case .improvement: return Color.cyan - case .review: return Color.orange - case .test: return Color.purple - case .doc: return Color.yellow - case .research: return Color.teal - case .etc: return Color.gray + case .issue: return .red + case .feature: return .green + case .improvement: return .cyan + case .review: return .orange + case .test: return .purple + case .doc: return .yellow + case .research: return .teal + case .etc: return .gray } } } diff --git a/DevLog/Presentation/Extension/TodoCategory+Presentation.swift b/DevLog/Presentation/Extension/TodoCategory+Presentation.swift new file mode 100644 index 00000000..7fd87c06 --- /dev/null +++ b/DevLog/Presentation/Extension/TodoCategory+Presentation.swift @@ -0,0 +1,61 @@ +// +// TodoCategory+Presentation.swift +// DevLog +// +// Created by opfic on 3/29/26. +// + +import SwiftUI + +extension TodoCategory: Identifiable { + var id: String { + switch self { + case .system(let category): + return category.id + case .user(let category): + return category.id + } + } +} + +extension TodoCategory: Hashable { + func hash(into hasher: inout Hasher) { + switch self { + case .system(let category): + hasher.combine(0) + hasher.combine(category) + case .user(let category): + hasher.combine(1) + hasher.combine(category) + } + } +} + +extension TodoCategory { + var symbolName: String { + switch self { + case .system(let category): + return category.symbolName + case .user(let category): + return category.symbolName + } + } + + var localizedName: String { + switch self { + case .system(let category): + return category.localizedName + case .user(let category): + return category.localizedName + } + } + + var color: Color { + switch self { + case .system(let category): + return category.color + case .user(let category): + return category.color + } + } +} diff --git a/DevLog/Presentation/Extension/UserTodoCategory+Presentation.swift b/DevLog/Presentation/Extension/UserTodoCategory+Presentation.swift new file mode 100644 index 00000000..9c62792b --- /dev/null +++ b/DevLog/Presentation/Extension/UserTodoCategory+Presentation.swift @@ -0,0 +1,28 @@ +// +// UserTodoCategory+Presentation.swift +// DevLog +// +// Created by opfic on 3/29/26. +// + +import SwiftUI + +extension UserTodoCategory: Identifiable { + var id: String { category } +} + +extension UserTodoCategory: Hashable { + func hash(into hasher: inout Hasher) { + hasher.combine(category) + hasher.combine(name) + hasher.combine(colorHex) + } +} + +extension UserTodoCategory { + var symbolName: String { "tray.fill" } + + var localizedName: String { name } + + var color: Color { .gray } +} diff --git a/DevLog/Presentation/Structure/Profile/ProfileSelectedDayActivity.swift b/DevLog/Presentation/Structure/Profile/ProfileSelectedDayActivity.swift index fb56c40e..9ad52abf 100644 --- a/DevLog/Presentation/Structure/Profile/ProfileSelectedDayActivity.swift +++ b/DevLog/Presentation/Structure/Profile/ProfileSelectedDayActivity.swift @@ -20,4 +20,16 @@ struct ProfileSelectedDayActivity: Identifiable, Hashable { } return showsCreated ? "생성" : "완료" } + + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.todo.id == rhs.todo.id + && lhs.showsCreated == rhs.showsCreated + && lhs.showsCompleted == rhs.showsCompleted + } + + func hash(into hasher: inout Hasher) { + hasher.combine(todo.id) + hasher.combine(showsCreated) + hasher.combine(showsCompleted) + } } diff --git a/DevLog/Presentation/ViewModel/HomeViewModel.swift b/DevLog/Presentation/ViewModel/HomeViewModel.swift index 809c8745..1e003ac0 100644 --- a/DevLog/Presentation/ViewModel/HomeViewModel.swift +++ b/DevLog/Presentation/ViewModel/HomeViewModel.swift @@ -11,8 +11,8 @@ import Combine @Observable final class HomeViewModel: Store { struct State: Equatable { - var todoCategoryPreferences = TodoCategory.allCases.map { - TodoCategoryPreference(category: $0, isVisible: true) + var todoCategoryPreferences = SystemTodoCategory.allCases.map { + TodoCategoryPreference(category: .system($0), isVisible: true) } var recentTodos: [RecentTodoItem] = [] var webPages: [WebPageItem] = [] diff --git a/DevLog/Presentation/ViewModel/TodoEditorViewModel.swift b/DevLog/Presentation/ViewModel/TodoEditorViewModel.swift index 813607f7..22d92722 100644 --- a/DevLog/Presentation/ViewModel/TodoEditorViewModel.swift +++ b/DevLog/Presentation/ViewModel/TodoEditorViewModel.swift @@ -57,7 +57,7 @@ final class TodoEditorViewModel: Store { var tagText: String = "" var focusOnEditor: Bool = false var tabViewTag: Tag = .editor - var category: TodoCategory = .etc + var category: TodoCategory = .system(.etc) var isValidToSave: Bool { !title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } diff --git a/DevLog/UI/Home/TodoEditorView.swift b/DevLog/UI/Home/TodoEditorView.swift index 58927a5e..df1dc831 100644 --- a/DevLog/UI/Home/TodoEditorView.swift +++ b/DevLog/UI/Home/TodoEditorView.swift @@ -215,9 +215,9 @@ private struct TodoEditorInfoSheetView: View { set: { viewModel.send(.setCategory($0)) } ) ) { - ForEach(TodoCategory.allCases) { category in + ForEach(SystemTodoCategory.allCases) { category in Text(category.localizedName) - .tag(category) + .tag(TodoCategory.system(category)) } } From 0360b8c167ae91f67f4e04e5e0f5ef9f51c25e83 Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 30 Mar 2026 08:50:41 +0900 Subject: [PATCH 02/29] =?UTF-8?q?feat:=20=EC=B9=B4=ED=85=8C=EA=B3=A0?= =?UTF-8?q?=EB=A6=AC=EC=99=80=20=EC=83=89=EC=83=81=EC=9D=84=20=EC=9E=85?= =?UTF-8?q?=EB=A0=A5=ED=95=98=EB=8A=94=20=ED=94=84=EB=A0=88=EC=A0=A0?= =?UTF-8?q?=ED=85=8C=EC=9D=B4=EC=85=98=20=EA=B5=AC=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DevLog/Domain/Entity/TodoCategory.swift | 2 +- DevLog/Domain/Entity/UserTodoCategory.swift | 1 - DevLog/Presentation/Extension/Color+Hex.swift | 44 ++++++++++++++ .../UserTodoCategory+Presentation.swift | 5 +- .../ViewModel/TodoManageViewModel.swift | 47 ++++++++++++++- DevLog/Resource/Localizable.xcstrings | 23 +++++--- DevLog/UI/Home/TodoManageView.swift | 59 +++++++++++++++++++ 7 files changed, 168 insertions(+), 13 deletions(-) create mode 100644 DevLog/Presentation/Extension/Color+Hex.swift diff --git a/DevLog/Domain/Entity/TodoCategory.swift b/DevLog/Domain/Entity/TodoCategory.swift index d4c97d2b..f89c1e26 100644 --- a/DevLog/Domain/Entity/TodoCategory.swift +++ b/DevLog/Domain/Entity/TodoCategory.swift @@ -16,7 +16,7 @@ enum TodoCategory: Equatable { case .system(let category): return category.rawValue case .user(let category): - return category.category + return category.name } } } diff --git a/DevLog/Domain/Entity/UserTodoCategory.swift b/DevLog/Domain/Entity/UserTodoCategory.swift index 7fb679f9..d5f0979a 100644 --- a/DevLog/Domain/Entity/UserTodoCategory.swift +++ b/DevLog/Domain/Entity/UserTodoCategory.swift @@ -8,7 +8,6 @@ import Foundation struct UserTodoCategory: Equatable { - let category: String var name: String var colorHex: String } diff --git a/DevLog/Presentation/Extension/Color+Hex.swift b/DevLog/Presentation/Extension/Color+Hex.swift new file mode 100644 index 00000000..d0046a11 --- /dev/null +++ b/DevLog/Presentation/Extension/Color+Hex.swift @@ -0,0 +1,44 @@ +// +// Color+Hex.swift +// DevLog +// +// Created by opfic on 3/30/26. +// + +import SwiftUI + +extension Color { + init?(hexString: String) { + let trimmedHex = hexString.trimmingCharacters(in: .whitespacesAndNewlines) + let sanitizedHex = trimmedHex.hasPrefix("#") ? String(trimmedHex.dropFirst()) : trimmedHex + + guard sanitizedHex.count == 6, + let hexValue = Int(sanitizedHex, radix: 16) else { + return nil + } + + let red = Double((hexValue >> 16) & 0xFF) / 255 + let green = Double((hexValue >> 8) & 0xFF) / 255 + let blue = Double(hexValue & 0xFF) / 255 + + self.init(red: red, green: green, blue: blue) + } + + var hexString: String? { + let uiColor = UIColor(self) + var red: CGFloat = 0 + var green: CGFloat = 0 + var blue: CGFloat = 0 + var alpha: CGFloat = 0 + + guard uiColor.getRed(&red, green: &green, blue: &blue, alpha: &alpha) else { + return nil + } + + let redValue = Int(round(red * 255)) + let greenValue = Int(round(green * 255)) + let blueValue = Int(round(blue * 255)) + + return String(format: "#%02X%02X%02X", redValue, greenValue, blueValue) + } +} diff --git a/DevLog/Presentation/Extension/UserTodoCategory+Presentation.swift b/DevLog/Presentation/Extension/UserTodoCategory+Presentation.swift index 9c62792b..a105b6c4 100644 --- a/DevLog/Presentation/Extension/UserTodoCategory+Presentation.swift +++ b/DevLog/Presentation/Extension/UserTodoCategory+Presentation.swift @@ -8,12 +8,11 @@ import SwiftUI extension UserTodoCategory: Identifiable { - var id: String { category } + var id: String { name } } extension UserTodoCategory: Hashable { func hash(into hasher: inout Hasher) { - hasher.combine(category) hasher.combine(name) hasher.combine(colorHex) } @@ -24,5 +23,5 @@ extension UserTodoCategory { var localizedName: String { name } - var color: Color { .gray } + var color: Color { Color(hexString: colorHex) ?? .gray } } diff --git a/DevLog/Presentation/ViewModel/TodoManageViewModel.swift b/DevLog/Presentation/ViewModel/TodoManageViewModel.swift index 0067a042..f4e12ff3 100644 --- a/DevLog/Presentation/ViewModel/TodoManageViewModel.swift +++ b/DevLog/Presentation/ViewModel/TodoManageViewModel.swift @@ -5,22 +5,39 @@ // Created by 최윤진 on 11/30/25. // -import Foundation +import SwiftUI @Observable final class TodoManageViewModel: Store { struct State: Equatable { var todoCategoryPreferences: [TodoCategoryPreference] + var showAddCategorySheet: Bool = false + var categoryName: String = "" + var categoryColor: Color = .blue } enum Action { case moveItem(from: IndexSet, target: Int) case tapItem(_ item: TodoCategory) + case setShowAddCategorySheet(Bool) + case setCategoryName(String) + case setCategoryColor(Color) + case addUserCategory } enum SideEffect { } private(set) var state: State + var canAddUserCategory: Bool { + let trimmedCategoryName = state.categoryName.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmedCategoryName.isEmpty { + return false + } + + return !SystemTodoCategory.allCases.contains { + $0.localizedName.localizedCaseInsensitiveCompare(trimmedCategoryName) == .orderedSame + } + } init(_ todoCategoryPreferences: [TodoCategoryPreference]) { self.state = State(todoCategoryPreferences: todoCategoryPreferences) @@ -36,6 +53,34 @@ final class TodoManageViewModel: Store { if let index = state.todoCategoryPreferences.firstIndex(where: { $0.category == item }) { state.todoCategoryPreferences[index].isVisible.toggle() } + case .setShowAddCategorySheet(let isPresented): + state.showAddCategorySheet = isPresented + if !isPresented { + state.categoryName = "" + state.categoryColor = .blue + } + case .setCategoryName(let name): + state.categoryName = name + case .setCategoryColor(let color): + state.categoryColor = color + case .addUserCategory: + let trimmedCategoryName = state.categoryName.trimmingCharacters(in: .whitespacesAndNewlines) + if let colorHex = state.categoryColor.hexString { + state.todoCategoryPreferences.append( + TodoCategoryPreference( + category: .user( + UserTodoCategory( + name: trimmedCategoryName, + colorHex: colorHex + ) + ), + isVisible: true + ) + ) + } + state.showAddCategorySheet = false + state.categoryName = "" + state.categoryColor = .blue } if self.state != state { self.state = state } diff --git a/DevLog/Resource/Localizable.xcstrings b/DevLog/Resource/Localizable.xcstrings index 867a75d6..838fe642 100644 --- a/DevLog/Resource/Localizable.xcstrings +++ b/DevLog/Resource/Localizable.xcstrings @@ -154,6 +154,12 @@ } } } + }, + "TODO" : { + + }, + "TODO 편집" : { + }, "todo_category_doc" : { "comment" : "Todo category: Documentation", @@ -242,12 +248,6 @@ } } } - }, - "TODO" : { - - }, - "TODO 편집" : { - }, "Todos" : { @@ -323,6 +323,9 @@ }, "상태 설정" : { + }, + "색상" : { + }, "생성일" : { @@ -450,6 +453,12 @@ }, "카테고리" : { + }, + "카테고리 추가" : { + + }, + "카테고리명" : { + }, "컨텐츠" : { @@ -504,4 +513,4 @@ } }, "version" : "1.0" -} +} \ No newline at end of file diff --git a/DevLog/UI/Home/TodoManageView.swift b/DevLog/UI/Home/TodoManageView.swift index 529801e7..1bb4f35a 100644 --- a/DevLog/UI/Home/TodoManageView.swift +++ b/DevLog/UI/Home/TodoManageView.swift @@ -36,7 +36,21 @@ struct TodoManageView: View { .navigationTitle("TODO 편집") .navigationBarTitleDisplayMode(.inline) .navigationBarBackButtonHidden() + .sheet(isPresented: Binding( + get: { viewModel.state.showAddCategorySheet }, + set: { viewModel.send(.setShowAddCategorySheet($0)) } + )) { + categorySheet + } .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button { + viewModel.send(.setShowAddCategorySheet(true)) + } label: { + Image(systemName: "plus") + } + } + ToolbarItem(placement: .navigationBarTrailing) { Button(action: { onDismiss?(viewModel.state.todoCategoryPreferences) @@ -47,4 +61,49 @@ struct TodoManageView: View { } } } + + private var categorySheet: some View { + NavigationStack { + Form { + Section { + TextField( + "카테고리명", + text: Binding( + get: { viewModel.state.categoryName }, + set: { viewModel.send(.setCategoryName($0)) } + ) + ) + .frame(height: UIFont.preferredFont(forTextStyle: .body).lineHeight) + } + + Section { + ColorPicker( + "색상", + selection: Binding( + get: { viewModel.state.categoryColor }, + set: { viewModel.send(.setCategoryColor($0)) } + ), + supportsOpacity: false + ) + .pickerStyle(.palette) + } + } + .navigationTitle("카테고리 추가") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("취소") { + viewModel.send(.setShowAddCategorySheet(false)) + } + } + + ToolbarItem(placement: .navigationBarTrailing) { + Button("추가") { + viewModel.send(.addUserCategory) + } + .disabled(!viewModel.canAddUserCategory) + } + } + } + } } From c1e40b0d307383d7469a47d17e0fe36ffe111373 Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 30 Mar 2026 09:53:11 +0900 Subject: [PATCH 03/29] =?UTF-8?q?feat:=20=EC=82=AC=EC=9A=A9=EC=9E=90=20?= =?UTF-8?q?=EC=BB=A4=EC=8A=A4=ED=85=80=20=EC=B9=B4=ED=85=8C=EA=B3=A0?= =?UTF-8?q?=EB=A6=AC=EB=8A=94=20=EC=82=AD=EC=A0=9C=20=EA=B0=80=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Presentation/ViewModel/TodoManageViewModel.swift | 5 +++++ DevLog/UI/Home/TodoManageView.swift | 10 ++++++++++ 2 files changed, 15 insertions(+) diff --git a/DevLog/Presentation/ViewModel/TodoManageViewModel.swift b/DevLog/Presentation/ViewModel/TodoManageViewModel.swift index f4e12ff3..c559f899 100644 --- a/DevLog/Presentation/ViewModel/TodoManageViewModel.swift +++ b/DevLog/Presentation/ViewModel/TodoManageViewModel.swift @@ -19,6 +19,7 @@ final class TodoManageViewModel: Store { enum Action { case moveItem(from: IndexSet, target: Int) case tapItem(_ item: TodoCategory) + case deleteUserCategory(TodoCategoryPreference) case setShowAddCategorySheet(Bool) case setCategoryName(String) case setCategoryColor(Color) @@ -53,6 +54,10 @@ final class TodoManageViewModel: Store { if let index = state.todoCategoryPreferences.firstIndex(where: { $0.category == item }) { state.todoCategoryPreferences[index].isVisible.toggle() } + case .deleteUserCategory(let preference): + if let index = state.todoCategoryPreferences.firstIndex(where: { $0 == preference }) { + state.todoCategoryPreferences.remove(at: index) + } case .setShowAddCategorySheet(let isPresented): state.showAddCategorySheet = isPresented if !isPresented { diff --git a/DevLog/UI/Home/TodoManageView.swift b/DevLog/UI/Home/TodoManageView.swift index 1bb4f35a..b1ba5143 100644 --- a/DevLog/UI/Home/TodoManageView.swift +++ b/DevLog/UI/Home/TodoManageView.swift @@ -23,6 +23,16 @@ struct TodoManageView: View { viewModel.send(.tapItem(category)) } Text(category.localizedName) + Spacer() + if case .user = category { + Button(role: .destructive) { + viewModel.send(.deleteUserCategory(preference)) + } label: { + Image(systemName: "trash") + } + .buttonStyle(.borderless) + .padding(.trailing) + } } } .onMove { (source: IndexSet, destination: Int) in From f314b998ebd9bc626db3ea959ebfdea80b610fd9 Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 30 Mar 2026 09:58:14 +0900 Subject: [PATCH 04/29] =?UTF-8?q?ui:=20=EC=83=89=EC=83=81=20=EB=8C=80?= =?UTF-8?q?=EC=8B=A0=20=EC=BD=94=EB=93=9C=EB=A5=BC=20=EB=B3=B4=EC=97=AC?= =?UTF-8?q?=EC=A3=BC=EA=B3=A0,=20=ED=95=B4=EB=8B=B9=20=EC=83=89=EC=83=81?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=ED=8F=B0=ED=8A=B8=20=EC=83=89=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DevLog/Resource/Localizable.xcstrings | 3 --- DevLog/UI/Home/TodoManageView.swift | 15 +++++++-------- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/DevLog/Resource/Localizable.xcstrings b/DevLog/Resource/Localizable.xcstrings index 838fe642..b0852b96 100644 --- a/DevLog/Resource/Localizable.xcstrings +++ b/DevLog/Resource/Localizable.xcstrings @@ -323,9 +323,6 @@ }, "상태 설정" : { - }, - "색상" : { - }, "생성일" : { diff --git a/DevLog/UI/Home/TodoManageView.swift b/DevLog/UI/Home/TodoManageView.swift index b1ba5143..fd930c7a 100644 --- a/DevLog/UI/Home/TodoManageView.swift +++ b/DevLog/UI/Home/TodoManageView.swift @@ -87,14 +87,13 @@ struct TodoManageView: View { } Section { - ColorPicker( - "색상", - selection: Binding( - get: { viewModel.state.categoryColor }, - set: { viewModel.send(.setCategoryColor($0)) } - ), - supportsOpacity: false - ) + ColorPicker(selection: Binding( + get: { viewModel.state.categoryColor }, + set: { viewModel.send(.setCategoryColor($0)) } + ), supportsOpacity: false) { + Text(viewModel.state.categoryColor.hexString ?? "#") + .foregroundStyle(viewModel.state.categoryColor) + } .pickerStyle(.palette) } } From 7ae6773e3ca307393baef4c4fcd80652c7def9ba Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 30 Mar 2026 10:32:22 +0900 Subject: [PATCH 05/29] =?UTF-8?q?chore:=20=EC=95=84=ED=82=A4=ED=85=8D?= =?UTF-8?q?=EC=B3=90=20=ED=8A=B9=EC=84=B1=20=EC=83=81=20=EB=B0=9C=EC=83=9D?= =?UTF-8?q?=ED=95=98=EB=8A=94=20=ED=8C=A8=ED=84=B4=20disable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .swiftlint.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.swiftlint.yml b/.swiftlint.yml index 6767d1a4..d763bfca 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -3,3 +3,5 @@ disabled_rules: - multiple_closures_with_trailing_closure - trailing_whitespace - type_body_length + - cyclomatic_complexity + - function_body_length From a98a0a048dfafaaf7d2b407f4cd809c472595b9d Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 30 Mar 2026 11:35:19 +0900 Subject: [PATCH 06/29] =?UTF-8?q?feat:=20=EC=84=9C=EB=B9=84=EC=8A=A4=20?= =?UTF-8?q?=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC=20=EC=84=A4=EC=A0=95?= =?UTF-8?q?=EA=B3=BC=20=EC=82=AC=EC=9A=A9=EC=9E=90=20=EC=B9=B4=ED=85=8C?= =?UTF-8?q?=EA=B3=A0=EB=A6=AC=20=EC=84=A4=EC=A0=95=EC=9D=84=20Firestore?= =?UTF-8?q?=EC=9D=84=20=ED=86=B5=ED=95=B4=20=EC=98=81=EC=86=8D=EC=84=B1?= =?UTF-8?q?=EC=9D=84=20=EC=A7=80=EC=9B=90=ED=95=98=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DevLog/App/Assembler/DataAssembler.swift | 6 + DevLog/App/Assembler/DomainAssembler.swift | 15 ++ DevLog/App/Assembler/InfraAssembler.swift | 4 + .../TodoCategoryRepositoryImpl.swift | 22 +++ .../Protocol/TodoCategoryRepository.swift | 11 ++ .../FetchTodoCategoryPreferencesUseCase.swift | 10 + ...chTodoCategoryPreferencesUseCaseImpl.swift | 18 ++ ...UpdateTodoCategoryPreferencesUseCase.swift | 10 + ...teTodoCategoryPreferencesUseCaseImpl.swift | 18 ++ DevLog/Infra/Common/FirestorePath.swift | 1 + .../Infra/Service/TodoCategoryService.swift | 176 ++++++++++++++++++ .../ViewModel/HomeViewModel.swift | 34 +++- DevLog/UI/Common/MainView.swift | 2 + 13 files changed, 325 insertions(+), 2 deletions(-) create mode 100644 DevLog/Data/Repository/TodoCategoryRepositoryImpl.swift create mode 100644 DevLog/Domain/Protocol/TodoCategoryRepository.swift create mode 100644 DevLog/Domain/UseCase/TodoCategory/Fetch/FetchTodoCategoryPreferencesUseCase.swift create mode 100644 DevLog/Domain/UseCase/TodoCategory/Fetch/FetchTodoCategoryPreferencesUseCaseImpl.swift create mode 100644 DevLog/Domain/UseCase/TodoCategory/Update/UpdateTodoCategoryPreferencesUseCase.swift create mode 100644 DevLog/Domain/UseCase/TodoCategory/Update/UpdateTodoCategoryPreferencesUseCaseImpl.swift create mode 100644 DevLog/Infra/Service/TodoCategoryService.swift diff --git a/DevLog/App/Assembler/DataAssembler.swift b/DevLog/App/Assembler/DataAssembler.swift index 33feab2f..6419c724 100644 --- a/DevLog/App/Assembler/DataAssembler.swift +++ b/DevLog/App/Assembler/DataAssembler.swift @@ -32,6 +32,12 @@ final class DataAssembler: Assembler { ) } + container.register(TodoCategoryRepository.self) { + TodoCategoryRepositoryImpl( + todoCategoryService: container.resolve(TodoCategoryService.self) + ) + } + container.register(AuthSessionRepository.self) { AuthSessionRepositoryImpl( authService: container.resolve(AuthService.self) diff --git a/DevLog/App/Assembler/DomainAssembler.swift b/DevLog/App/Assembler/DomainAssembler.swift index d9f3da61..da962efc 100644 --- a/DevLog/App/Assembler/DomainAssembler.swift +++ b/DevLog/App/Assembler/DomainAssembler.swift @@ -11,6 +11,7 @@ final class DomainAssembler: Assembler { registerConnectivityUseCases(container) registerAuthProviderUseCases(container) registerTodoUseCases(container) + registerTodoCategoryUseCases(container) registerUserDataUseCases(container) registerPushNotificationUseCases(container) registerWebPageUseCases(container) @@ -85,6 +86,20 @@ private extension DomainAssembler { } } + func registerTodoCategoryUseCases(_ container: DIContainer) { + container.register(FetchTodoCategoryPreferencesUseCase.self) { + FetchTodoCategoryPreferencesUseCaseImpl( + container.resolve(TodoCategoryRepository.self) + ) + } + + container.register(UpdateTodoCategoryPreferencesUseCase.self) { + UpdateTodoCategoryPreferencesUseCaseImpl( + container.resolve(TodoCategoryRepository.self) + ) + } + } + func registerUserDataUseCases(_ container: DIContainer) { container.register(FetchUserDataUseCase.self) { FetchUserDataUseCaseImpl(container.resolve(UserDataRepository.self)) diff --git a/DevLog/App/Assembler/InfraAssembler.swift b/DevLog/App/Assembler/InfraAssembler.swift index 514dae6e..33fcce01 100644 --- a/DevLog/App/Assembler/InfraAssembler.swift +++ b/DevLog/App/Assembler/InfraAssembler.swift @@ -36,6 +36,10 @@ final class InfraAssembler: Assembler { TodoService() } + container.register(TodoCategoryService.self) { + TodoCategoryService() + } + container.register(UserService.self) { UserService() } diff --git a/DevLog/Data/Repository/TodoCategoryRepositoryImpl.swift b/DevLog/Data/Repository/TodoCategoryRepositoryImpl.swift new file mode 100644 index 00000000..1689b9cb --- /dev/null +++ b/DevLog/Data/Repository/TodoCategoryRepositoryImpl.swift @@ -0,0 +1,22 @@ +// +// TodoCategoryRepositoryImpl.swift +// DevLog +// +// Created by opfic on 3/30/26. +// + +final class TodoCategoryRepositoryImpl: TodoCategoryRepository { + private let todoCategoryService: TodoCategoryService + + init(todoCategoryService: TodoCategoryService) { + self.todoCategoryService = todoCategoryService + } + + func fetchPreferences() async throws -> [TodoCategoryPreference] { + try await todoCategoryService.fetchPreferences() + } + + func updatePreferences(_ preferences: [TodoCategoryPreference]) async throws { + try await todoCategoryService.updatePreferences(preferences) + } +} diff --git a/DevLog/Domain/Protocol/TodoCategoryRepository.swift b/DevLog/Domain/Protocol/TodoCategoryRepository.swift new file mode 100644 index 00000000..485bc0c5 --- /dev/null +++ b/DevLog/Domain/Protocol/TodoCategoryRepository.swift @@ -0,0 +1,11 @@ +// +// TodoCategoryRepository.swift +// DevLog +// +// Created by opfic on 3/30/26. +// + +protocol TodoCategoryRepository { + func fetchPreferences() async throws -> [TodoCategoryPreference] + func updatePreferences(_ preferences: [TodoCategoryPreference]) async throws +} diff --git a/DevLog/Domain/UseCase/TodoCategory/Fetch/FetchTodoCategoryPreferencesUseCase.swift b/DevLog/Domain/UseCase/TodoCategory/Fetch/FetchTodoCategoryPreferencesUseCase.swift new file mode 100644 index 00000000..d7ee10d9 --- /dev/null +++ b/DevLog/Domain/UseCase/TodoCategory/Fetch/FetchTodoCategoryPreferencesUseCase.swift @@ -0,0 +1,10 @@ +// +// FetchTodoCategoryPreferencesUseCase.swift +// DevLog +// +// Created by opfic on 3/30/26. +// + +protocol FetchTodoCategoryPreferencesUseCase { + func execute() async throws -> [TodoCategoryPreference] +} diff --git a/DevLog/Domain/UseCase/TodoCategory/Fetch/FetchTodoCategoryPreferencesUseCaseImpl.swift b/DevLog/Domain/UseCase/TodoCategory/Fetch/FetchTodoCategoryPreferencesUseCaseImpl.swift new file mode 100644 index 00000000..56c51adc --- /dev/null +++ b/DevLog/Domain/UseCase/TodoCategory/Fetch/FetchTodoCategoryPreferencesUseCaseImpl.swift @@ -0,0 +1,18 @@ +// +// FetchTodoCategoryPreferencesUseCaseImpl.swift +// DevLog +// +// Created by opfic on 3/30/26. +// + +final class FetchTodoCategoryPreferencesUseCaseImpl: FetchTodoCategoryPreferencesUseCase { + private let todoCategoryRepository: TodoCategoryRepository + + init(_ todoCategoryRepository: TodoCategoryRepository) { + self.todoCategoryRepository = todoCategoryRepository + } + + func execute() async throws -> [TodoCategoryPreference] { + try await todoCategoryRepository.fetchPreferences() + } +} diff --git a/DevLog/Domain/UseCase/TodoCategory/Update/UpdateTodoCategoryPreferencesUseCase.swift b/DevLog/Domain/UseCase/TodoCategory/Update/UpdateTodoCategoryPreferencesUseCase.swift new file mode 100644 index 00000000..359f47c4 --- /dev/null +++ b/DevLog/Domain/UseCase/TodoCategory/Update/UpdateTodoCategoryPreferencesUseCase.swift @@ -0,0 +1,10 @@ +// +// UpdateTodoCategoryPreferencesUseCase.swift +// DevLog +// +// Created by opfic on 3/30/26. +// + +protocol UpdateTodoCategoryPreferencesUseCase { + func execute(_ preferences: [TodoCategoryPreference]) async throws +} diff --git a/DevLog/Domain/UseCase/TodoCategory/Update/UpdateTodoCategoryPreferencesUseCaseImpl.swift b/DevLog/Domain/UseCase/TodoCategory/Update/UpdateTodoCategoryPreferencesUseCaseImpl.swift new file mode 100644 index 00000000..a5e04993 --- /dev/null +++ b/DevLog/Domain/UseCase/TodoCategory/Update/UpdateTodoCategoryPreferencesUseCaseImpl.swift @@ -0,0 +1,18 @@ +// +// UpdateTodoCategoryPreferencesUseCaseImpl.swift +// DevLog +// +// Created by opfic on 3/30/26. +// + +final class UpdateTodoCategoryPreferencesUseCaseImpl: UpdateTodoCategoryPreferencesUseCase { + private let todoCategoryRepository: TodoCategoryRepository + + init(_ todoCategoryRepository: TodoCategoryRepository) { + self.todoCategoryRepository = todoCategoryRepository + } + + func execute(_ preferences: [TodoCategoryPreference]) async throws { + try await todoCategoryRepository.updatePreferences(preferences) + } +} diff --git a/DevLog/Infra/Common/FirestorePath.swift b/DevLog/Infra/Common/FirestorePath.swift index cad439bd..60b4b66e 100644 --- a/DevLog/Infra/Common/FirestorePath.swift +++ b/DevLog/Infra/Common/FirestorePath.swift @@ -19,6 +19,7 @@ enum FirestorePath { case info case tokens case settings + case categories } enum Counter: String { diff --git a/DevLog/Infra/Service/TodoCategoryService.swift b/DevLog/Infra/Service/TodoCategoryService.swift new file mode 100644 index 00000000..7fa18511 --- /dev/null +++ b/DevLog/Infra/Service/TodoCategoryService.swift @@ -0,0 +1,176 @@ +// +// TodoCategoryService.swift +// DevLog +// +// Created by opfic on 3/30/26. +// + +import FirebaseAuth +import FirebaseFirestore + +final class TodoCategoryService { + private enum Field: String { + case items + case kind + case systemCategory + case name + case colorHex + case isVisible + } + + private enum Kind: String { + case system + case user + } + + private let store = Firestore.firestore() + private let logger = Logger(category: "TodoCategoryService") + + func fetchPreferences() async throws -> [TodoCategoryPreference] { + guard let uid = Auth.auth().currentUser?.uid else { + logger.error("User not authenticated") + throw AuthError.notAuthenticated + } + + logger.info("Fetching todo category preferences") + + do { + let snapshot = try await store.document( + FirestorePath.userData(uid, document: .categories) + ).getDocument() + + guard let items = snapshot.data()?[Field.items.rawValue] as? [[String: Any]] else { + logger.info("Todo category preferences not found, using defaults") + return SystemTodoCategory.allCases.map { + TodoCategoryPreference(category: .system($0), isVisible: true) + } + } + + let preferences = items.compactMap { makePreference($0) } + if preferences.isEmpty { + logger.info("Todo category preferences empty, using defaults") + return SystemTodoCategory.allCases.map { + TodoCategoryPreference(category: .system($0), isVisible: true) + } + } + + let mergedPreferences = mergedPreferences(preferences) + logger.info("Successfully fetched todo category preferences") + return mergedPreferences + } catch { + logger.error("Failed to fetch todo category preferences", error: error) + throw error + } + } + + func updatePreferences(_ preferences: [TodoCategoryPreference]) async throws { + guard let uid = Auth.auth().currentUser?.uid else { + logger.error("User not authenticated") + throw AuthError.notAuthenticated + } + + logger.info("Updating todo category preferences") + + do { + try await store.document( + FirestorePath.userData(uid, document: .categories) + ).setData( + [Field.items.rawValue: preferences.map(toDictionary)], + merge: true + ) + logger.info("Successfully updated todo category preferences") + } catch { + logger.error("Failed to update todo category preferences", error: error) + throw error + } + } +} + +private extension TodoCategoryService { + func mergedPreferences( + _ preferences: [TodoCategoryPreference] + ) -> [TodoCategoryPreference] { + var mergedPreferences = preferences + + for systemTodoCategory in SystemTodoCategory.allCases { + let containsSystemTodoCategory = preferences.contains { preference in + guard case .system(let currentSystemTodoCategory) = preference.category else { + return false + } + + return currentSystemTodoCategory == systemTodoCategory + } + + if containsSystemTodoCategory { continue } + + mergedPreferences.append( + TodoCategoryPreference( + category: .system(systemTodoCategory), + isVisible: true + ) + ) + } + + return mergedPreferences + } + + func makePreference(_ items: [String: Any]) -> TodoCategoryPreference? { + guard + let kindString = items[Field.kind.rawValue] as? String, + let kind = Kind(rawValue: kindString), + let isVisible = items[Field.isVisible.rawValue] as? Bool + else { + return nil + } + + switch kind { + case .system: + guard + let systemCategoryString = items[Field.systemCategory.rawValue] as? String, + let systemTodoCategory = SystemTodoCategory(rawValue: systemCategoryString) + else { + return nil + } + + return TodoCategoryPreference( + category: .system(systemTodoCategory), + isVisible: isVisible + ) + case .user: + guard + let name = items[Field.name.rawValue] as? String, + let colorHex = items[Field.colorHex.rawValue] as? String + else { + return nil + } + + return TodoCategoryPreference( + category: .user( + UserTodoCategory( + name: name, + colorHex: colorHex + ) + ), + isVisible: isVisible + ) + } + } + + func toDictionary(_ preference: TodoCategoryPreference) -> [String: Any] { + switch preference.category { + case .system(let systemTodoCategory): + return [ + Field.kind.rawValue: Kind.system.rawValue, + Field.systemCategory.rawValue: systemTodoCategory.rawValue, + Field.isVisible.rawValue: preference.isVisible + ] + case .user(let userTodoCategory): + return [ + Field.kind.rawValue: Kind.user.rawValue, + Field.name.rawValue: userTodoCategory.name, + Field.colorHex.rawValue: userTodoCategory.colorHex, + Field.isVisible.rawValue: preference.isVisible + ] + } + } +} diff --git a/DevLog/Presentation/ViewModel/HomeViewModel.swift b/DevLog/Presentation/ViewModel/HomeViewModel.swift index 1e003ac0..16c35d3b 100644 --- a/DevLog/Presentation/ViewModel/HomeViewModel.swift +++ b/DevLog/Presentation/ViewModel/HomeViewModel.swift @@ -44,6 +44,7 @@ final class HomeViewModel: Store { case setLoading(LoadingTarget, Bool) case tapTodoCategory(TodoCategory) case orderTodoCategoryPreferences([TodoCategoryPreference]) + case setTodoCategoryPreferences([TodoCategoryPreference]) case addTodo(Todo) case updateRecentTodos([RecentTodoItem]) case updateWebPageURLInput(String) @@ -59,6 +60,8 @@ final class HomeViewModel: Store { case addWebPage(String) case deleteWebPage(WebPageItem, Int) case undoDeleteWebPage(String) + case fetchTodoCategoryPreferences + case updateTodoCategoryPreferences([TodoCategoryPreference]) case fetchRecentTodos case fetchWebPages case showModalAfterDelay(ModalType) @@ -93,6 +96,8 @@ final class HomeViewModel: Store { } private(set) var state = State() + private let fetchPreferencesUseCase: FetchTodoCategoryPreferencesUseCase + private let updatePreferencesUseCase: UpdateTodoCategoryPreferencesUseCase private let upsertTodoUseCase: UpsertTodoUseCase private let addWebPageUseCase: AddWebPageUseCase private let deleteWebPageUseCase: DeleteWebPageUseCase @@ -105,6 +110,8 @@ final class HomeViewModel: Store { private var cancellables = Set() init( + fetchPreferencesUseCase: FetchTodoCategoryPreferencesUseCase, + updatePreferencesUseCase: UpdateTodoCategoryPreferencesUseCase, addWebPageUseCase: AddWebPageUseCase, deleteWebPageUseCase: DeleteWebPageUseCase, undoDeleteWebPageUseCase: UndoDeleteWebPageUseCase, @@ -113,6 +120,8 @@ final class HomeViewModel: Store { fetchWebPagesUseCase: FetchWebPagesUseCase, networkConnectivityUseCase: ObserveNetworkConnectivityUseCase ) { + self.fetchPreferencesUseCase = fetchPreferencesUseCase + self.updatePreferencesUseCase = updatePreferencesUseCase self.addWebPageUseCase = addWebPageUseCase self.deleteWebPageUseCase = deleteWebPageUseCase self.undoDeleteWebPageUseCase = undoDeleteWebPageUseCase @@ -136,7 +145,8 @@ final class HomeViewModel: Store { .addWebPage, .deleteWebPage, .undoDeleteWebPage: effects = reduceByView(action, state: &state) - case .setLoading, .updateRecentTodos, .updateWebPages, .restoreWebPage: + case .setLoading, .setTodoCategoryPreferences, .updateRecentTodos, + .updateWebPages, .restoreWebPage: effects = reduceByRun(action, state: &state) } @@ -146,6 +156,23 @@ final class HomeViewModel: Store { func run(_ effect: SideEffect) { switch effect { + case .fetchTodoCategoryPreferences: + Task { + do { + let preferences = try await fetchPreferencesUseCase.execute() + send(.setTodoCategoryPreferences(preferences)) + } catch { + send(.setAlert(isPresented: true, type: .error)) + } + } + case .updateTodoCategoryPreferences(let items): + Task { + do { + try await updatePreferencesUseCase.execute(items) + } catch { + send(.setAlert(isPresented: true, type: .error)) + } + } case .addTodo(let todo): beginLoading(for: .overlay, mode: .delayed) Task { @@ -255,7 +282,7 @@ private extension HomeViewModel { func reduceByView(_ action: Action, state: inout State) -> [SideEffect] { switch action { case .onAppear: - return [.fetchRecentTodos, .fetchWebPages] + return [.fetchTodoCategoryPreferences, .fetchRecentTodos, .fetchWebPages] case .setPresentation(let presentation, let isPresented): setPresentation(&state, presentation: presentation, isPresented: isPresented) case .setAlert(let presented, let type): @@ -275,6 +302,7 @@ private extension HomeViewModel { return [.showModalAfterDelay(.todoEditor)] case .orderTodoCategoryPreferences(let preferences): state.todoCategoryPreferences = preferences + return [.updateTodoCategoryPreferences(preferences)] case .addTodo(let todo): return [.addTodo(todo)] case .updateWebPageURLInput(let text): @@ -308,6 +336,8 @@ private extension HomeViewModel { switch action { case .setLoading(let loadingTarget, let isLoading): setLoading(&state, loadingTarget: loadingTarget, isLoading: isLoading) + case .setTodoCategoryPreferences(let todoCategoryPreferenceArray): + state.todoCategoryPreferences = todoCategoryPreferenceArray case .updateRecentTodos(let todos): state.recentTodos = todos case .updateWebPages(let pages): diff --git a/DevLog/UI/Common/MainView.swift b/DevLog/UI/Common/MainView.swift index daab8800..089dab58 100644 --- a/DevLog/UI/Common/MainView.swift +++ b/DevLog/UI/Common/MainView.swift @@ -14,6 +14,8 @@ struct MainView: View { var body: some View { TabView { HomeView(viewModel: HomeViewModel( + fetchPreferencesUseCase: container.resolve(FetchTodoCategoryPreferencesUseCase.self), + updatePreferencesUseCase: container.resolve(UpdateTodoCategoryPreferencesUseCase.self), addWebPageUseCase: container.resolve(AddWebPageUseCase.self), deleteWebPageUseCase: container.resolve(DeleteWebPageUseCase.self), undoDeleteWebPageUseCase: container.resolve(UndoDeleteWebPageUseCase.self), From a38d34f394a5fcc3b5a4b7a555fdcd9fdde9a029 Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 30 Mar 2026 11:57:10 +0900 Subject: [PATCH 07/29] =?UTF-8?q?ui:=20=EC=82=AC=EC=9A=A9=EC=9E=90?= =?UTF-8?q?=EA=B0=80=20=EB=82=B4=EB=A6=B4=20=EC=88=98=20=EC=9E=88=EB=8B=A4?= =?UTF-8?q?=EB=8A=94=20=ED=91=9C=EC=8B=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DevLog/UI/Home/TodoManageView.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/DevLog/UI/Home/TodoManageView.swift b/DevLog/UI/Home/TodoManageView.swift index fd930c7a..713348b1 100644 --- a/DevLog/UI/Home/TodoManageView.swift +++ b/DevLog/UI/Home/TodoManageView.swift @@ -70,6 +70,7 @@ struct TodoManageView: View { } } } + .presentationDragIndicator(.visible) } private var categorySheet: some View { From 6a120a84d8c776770d7a966db0e9105c0c3d9cfc Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 30 Mar 2026 12:17:34 +0900 Subject: [PATCH 08/29] =?UTF-8?q?feat:=20=EC=BB=A4=EC=8A=A4=ED=85=80=20?= =?UTF-8?q?=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC=EB=A5=BC=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=ED=95=98=EB=A9=B4=20=EC=96=BC=EB=9F=BF=EC=9D=B4=20?= =?UTF-8?q?=EB=9C=A8=EA=B3=A0=20=ED=99=95=EC=9D=B8=20=EB=B2=84=ED=8A=BC?= =?UTF-8?q?=EC=9D=84=20=EB=88=8C=EB=9F=AC=EC=95=BC=20=EC=82=AD=EC=A0=9C?= =?UTF-8?q?=EB=90=98=EB=8F=84=EB=A1=9D=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ViewModel/HomeViewModel.swift | 8 +-- .../ViewModel/TodoManageViewModel.swift | 52 +++++++++++++------ DevLog/Resource/Localizable.xcstrings | 6 +++ DevLog/UI/Home/HomeView.swift | 6 +-- DevLog/UI/Home/TodoManageView.swift | 31 ++++++++--- 5 files changed, 73 insertions(+), 30 deletions(-) diff --git a/DevLog/Presentation/ViewModel/HomeViewModel.swift b/DevLog/Presentation/ViewModel/HomeViewModel.swift index 16c35d3b..e1927f70 100644 --- a/DevLog/Presentation/ViewModel/HomeViewModel.swift +++ b/DevLog/Presentation/ViewModel/HomeViewModel.swift @@ -11,7 +11,7 @@ import Combine @Observable final class HomeViewModel: Store { struct State: Equatable { - var todoCategoryPreferences = SystemTodoCategory.allCases.map { + var preferences = SystemTodoCategory.allCases.map { TodoCategoryPreference(category: .system($0), isVisible: true) } var recentTodos: [RecentTodoItem] = [] @@ -301,7 +301,7 @@ private extension HomeViewModel { state.showContentPicker = false return [.showModalAfterDelay(.todoEditor)] case .orderTodoCategoryPreferences(let preferences): - state.todoCategoryPreferences = preferences + state.preferences = preferences return [.updateTodoCategoryPreferences(preferences)] case .addTodo(let todo): return [.addTodo(todo)] @@ -336,8 +336,8 @@ private extension HomeViewModel { switch action { case .setLoading(let loadingTarget, let isLoading): setLoading(&state, loadingTarget: loadingTarget, isLoading: isLoading) - case .setTodoCategoryPreferences(let todoCategoryPreferenceArray): - state.todoCategoryPreferences = todoCategoryPreferenceArray + case .setTodoCategoryPreferences(let preferences): + state.preferences = preferences case .updateRecentTodos(let todos): state.recentTodos = todos case .updateWebPages(let pages): diff --git a/DevLog/Presentation/ViewModel/TodoManageViewModel.swift b/DevLog/Presentation/ViewModel/TodoManageViewModel.swift index c559f899..6c961ae8 100644 --- a/DevLog/Presentation/ViewModel/TodoManageViewModel.swift +++ b/DevLog/Presentation/ViewModel/TodoManageViewModel.swift @@ -10,8 +10,10 @@ import SwiftUI @Observable final class TodoManageViewModel: Store { struct State: Equatable { - var todoCategoryPreferences: [TodoCategoryPreference] - var showAddCategorySheet: Bool = false + var preferences: [TodoCategoryPreference] + var showSheet: Bool = false + var showAlert: Bool = false + var deletingPreference: TodoCategoryPreference? var categoryName: String = "" var categoryColor: Color = .blue } @@ -19,8 +21,10 @@ final class TodoManageViewModel: Store { enum Action { case moveItem(from: IndexSet, target: Int) case tapItem(_ item: TodoCategory) - case deleteUserCategory(TodoCategoryPreference) - case setShowAddCategorySheet(Bool) + case tapDeleteUserCategory(TodoCategoryPreference) + case confirmDeleteUserCategory + case setShowSheet(Bool) + case setShowAlert(Bool) case setCategoryName(String) case setCategoryColor(Color) case addUserCategory @@ -40,8 +44,8 @@ final class TodoManageViewModel: Store { } } - init(_ todoCategoryPreferences: [TodoCategoryPreference]) { - self.state = State(todoCategoryPreferences: todoCategoryPreferences) + init(_ preferences: [TodoCategoryPreference]) { + self.state = State(preferences: preferences) } func reduce(with action: Action) -> [SideEffect] { @@ -49,21 +53,37 @@ final class TodoManageViewModel: Store { switch action { case .moveItem(let from, let target): - state.todoCategoryPreferences.move(fromOffsets: from, toOffset: target) + state.preferences.move(fromOffsets: from, toOffset: target) case .tapItem(let item): - if let index = state.todoCategoryPreferences.firstIndex(where: { $0.category == item }) { - state.todoCategoryPreferences[index].isVisible.toggle() + if let index = state.preferences.firstIndex(where: { $0.category == item }) { + state.preferences[index].isVisible.toggle() } - case .deleteUserCategory(let preference): - if let index = state.todoCategoryPreferences.firstIndex(where: { $0 == preference }) { - state.todoCategoryPreferences.remove(at: index) + case .tapDeleteUserCategory(let preference): + state.deletingPreference = preference + state.showAlert = true + case .confirmDeleteUserCategory: + guard let preference = state.deletingPreference else { + break } - case .setShowAddCategorySheet(let isPresented): - state.showAddCategorySheet = isPresented + + if let index = state.preferences.firstIndex(where: { + $0 == preference + }) { + state.preferences.remove(at: index) + } + state.showAlert = false + state.deletingPreference = nil + case .setShowSheet(let isPresented): + state.showSheet = isPresented if !isPresented { state.categoryName = "" state.categoryColor = .blue } + case .setShowAlert(let isPresented): + state.showAlert = isPresented + if !isPresented { + state.deletingPreference = nil + } case .setCategoryName(let name): state.categoryName = name case .setCategoryColor(let color): @@ -71,7 +91,7 @@ final class TodoManageViewModel: Store { case .addUserCategory: let trimmedCategoryName = state.categoryName.trimmingCharacters(in: .whitespacesAndNewlines) if let colorHex = state.categoryColor.hexString { - state.todoCategoryPreferences.append( + state.preferences.append( TodoCategoryPreference( category: .user( UserTodoCategory( @@ -83,7 +103,7 @@ final class TodoManageViewModel: Store { ) ) } - state.showAddCategorySheet = false + state.showSheet = false state.categoryName = "" state.categoryColor = .blue } diff --git a/DevLog/Resource/Localizable.xcstrings b/DevLog/Resource/Localizable.xcstrings index b0852b96..cc88c0a7 100644 --- a/DevLog/Resource/Localizable.xcstrings +++ b/DevLog/Resource/Localizable.xcstrings @@ -380,6 +380,9 @@ }, "이 분기에는 선택한 활동이 없어요" : { + }, + "이 카테고리를 삭제하면 해당하던 TODO는 기타 카테고리로 처리됩니다.\n정말 삭제하시겠습니까?" : { + }, "읽지 않음" : { @@ -450,6 +453,9 @@ }, "카테고리" : { + }, + "카테고리 삭제" : { + }, "카테고리 추가" : { diff --git a/DevLog/UI/Home/HomeView.swift b/DevLog/UI/Home/HomeView.swift index 5303971a..fbe9a5b0 100644 --- a/DevLog/UI/Home/HomeView.swift +++ b/DevLog/UI/Home/HomeView.swift @@ -60,7 +60,7 @@ struct HomeView: View { set: { viewModel.send(.setPresentation(.reorderTodo, $0)) } )) { TodoManageView( - viewModel: TodoManageViewModel(viewModel.state.todoCategoryPreferences), + viewModel: TodoManageViewModel(viewModel.state.preferences), onDismiss: { array in viewModel.send(.setPresentation(.reorderTodo, false)) withAnimation { @@ -162,7 +162,7 @@ struct HomeView: View { private var todoSection: some View { Section(content: { - let preferences = viewModel.state.todoCategoryPreferences + let preferences = viewModel.state.preferences ForEach(preferences.filter { $0.isVisible }, id: \.id) { preference in let category = preference.category NavigationLink(value: Path.category(category)) { @@ -289,7 +289,7 @@ struct HomeView: View { NavigationStack { List { Section { - let preferences = viewModel.state.todoCategoryPreferences.filter(\.isVisible) + let preferences = viewModel.state.preferences.filter(\.isVisible) ForEach(preferences, id: \.id) { preference in let category = preference.category Button { diff --git a/DevLog/UI/Home/TodoManageView.swift b/DevLog/UI/Home/TodoManageView.swift index 713348b1..10406fe9 100644 --- a/DevLog/UI/Home/TodoManageView.swift +++ b/DevLog/UI/Home/TodoManageView.swift @@ -14,7 +14,7 @@ struct TodoManageView: View { var body: some View { NavigationStack { List { - ForEach(viewModel.state.todoCategoryPreferences, id: \.id) { preference in + ForEach(viewModel.state.preferences, id: \.id) { preference in let category = preference.category HStack(spacing: 0) { CheckBox(isChecked: preference.isVisible, font: .title3) @@ -26,7 +26,7 @@ struct TodoManageView: View { Spacer() if case .user = category { Button(role: .destructive) { - viewModel.send(.deleteUserCategory(preference)) + viewModel.send(.tapDeleteUserCategory(preference)) } label: { Image(systemName: "trash") } @@ -47,15 +47,32 @@ struct TodoManageView: View { .navigationBarTitleDisplayMode(.inline) .navigationBarBackButtonHidden() .sheet(isPresented: Binding( - get: { viewModel.state.showAddCategorySheet }, - set: { viewModel.send(.setShowAddCategorySheet($0)) } + get: { viewModel.state.showSheet }, + set: { viewModel.send(.setShowSheet($0)) } )) { categorySheet } + .alert( + "카테고리 삭제", + isPresented: Binding( + get: { viewModel.state.showAlert }, + set: { viewModel.send(.setShowAlert($0)) } + ) + ) { + Button("취소", role: .cancel) { + viewModel.send(.setShowAlert(false)) + } + Button("삭제", role: .destructive) { + viewModel.send(.confirmDeleteUserCategory) + } + } message: { + Text("이 카테고리를 삭제하면 해당하던 TODO는 기타 카테고리로 처리됩니다.\n정말 삭제하시겠습니까?") + .multilineTextAlignment(.leading) + } .toolbar { ToolbarItem(placement: .navigationBarLeading) { Button { - viewModel.send(.setShowAddCategorySheet(true)) + viewModel.send(.setShowSheet(true)) } label: { Image(systemName: "plus") } @@ -63,7 +80,7 @@ struct TodoManageView: View { ToolbarItem(placement: .navigationBarTrailing) { Button(action: { - onDismiss?(viewModel.state.todoCategoryPreferences) + onDismiss?(viewModel.state.preferences) }) { Text("완료") } @@ -103,7 +120,7 @@ struct TodoManageView: View { .toolbar { ToolbarItem(placement: .navigationBarLeading) { Button("취소") { - viewModel.send(.setShowAddCategorySheet(false)) + viewModel.send(.setShowSheet(false)) } } From 3aa493efc691e805f35f4abcf81987b4f385d93a Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 30 Mar 2026 12:33:35 +0900 Subject: [PATCH 09/29] =?UTF-8?q?feat:=20Firestore=EC=97=90=EC=84=9C=20fet?= =?UTF-8?q?ch=20=EC=8B=9C=20=EB=A1=9C=EB=94=A9=EB=B7=B0=EA=B0=80=20?= =?UTF-8?q?=EB=9C=A8=EB=8F=84=EB=A1=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ViewModel/HomeViewModel.swift | 10 ++-- DevLog/UI/Home/HomeView.swift | 50 +++++++++++-------- 2 files changed, 36 insertions(+), 24 deletions(-) diff --git a/DevLog/Presentation/ViewModel/HomeViewModel.swift b/DevLog/Presentation/ViewModel/HomeViewModel.swift index e1927f70..e8fe2667 100644 --- a/DevLog/Presentation/ViewModel/HomeViewModel.swift +++ b/DevLog/Presentation/ViewModel/HomeViewModel.swift @@ -11,9 +11,7 @@ import Combine @Observable final class HomeViewModel: Store { struct State: Equatable { - var preferences = SystemTodoCategory.allCases.map { - TodoCategoryPreference(category: .system($0), isVisible: true) - } + var preferences: [TodoCategoryPreference] = [] var recentTodos: [RecentTodoItem] = [] var webPages: [WebPageItem] = [] var isNetworkConnected: Bool = true @@ -23,6 +21,7 @@ final class HomeViewModel: Store { var webPageURLInput: String = "https://" var selectedTodoCategory: TodoCategory? var reorderTodo: Bool = false + var isPreferencesLoading: Bool = false var isRecentTodosLoading: Bool = false var isWebPageLoading: Bool = false var isAppending: Bool = false @@ -90,6 +89,7 @@ final class HomeViewModel: Store { } enum LoadingTarget: Hashable { + case preferences case recentTodos case webPage case overlay @@ -157,8 +157,10 @@ final class HomeViewModel: Store { func run(_ effect: SideEffect) { switch effect { case .fetchTodoCategoryPreferences: + beginLoading(for: .preferences, mode: .immediate) Task { do { + defer { endLoading(for: .preferences, mode: .immediate) } let preferences = try await fetchPreferencesUseCase.execute() send(.setTodoCategoryPreferences(preferences)) } catch { @@ -424,6 +426,8 @@ private extension HomeViewModel { isLoading: Bool ) { switch loadingTarget { + case .preferences: + state.isPreferencesLoading = isLoading case .recentTodos: state.isRecentTodosLoading = isLoading case .webPage: diff --git a/DevLog/UI/Home/HomeView.swift b/DevLog/UI/Home/HomeView.swift index fbe9a5b0..66cca5a7 100644 --- a/DevLog/UI/Home/HomeView.swift +++ b/DevLog/UI/Home/HomeView.swift @@ -162,15 +162,19 @@ struct HomeView: View { private var todoSection: some View { Section(content: { - let preferences = viewModel.state.preferences - ForEach(preferences.filter { $0.isVisible }, id: \.id) { preference in - let category = preference.category - NavigationLink(value: Path.category(category)) { - labelImage( - text: category.localizedName, - systemName: category.symbolName, - imageColor: category.color - ) + if viewModel.state.isPreferencesLoading { + LoadingView() + } else { + let preferences = viewModel.state.preferences + ForEach(preferences.filter { $0.isVisible }, id: \.id) { preference in + let category = preference.category + NavigationLink(value: Path.category(category)) { + labelImage( + text: category.localizedName, + systemName: category.symbolName, + imageColor: category.color + ) + } } } }, header: { @@ -289,19 +293,23 @@ struct HomeView: View { NavigationStack { List { Section { - let preferences = viewModel.state.preferences.filter(\.isVisible) - ForEach(preferences, id: \.id) { preference in - let category = preference.category - Button { - DispatchQueue.main.async { - viewModel.send(.tapTodoCategory(category)) + if viewModel.state.isPreferencesLoading { + LoadingView() + } else { + let preferences = viewModel.state.preferences.filter(\.isVisible) + ForEach(preferences, id: \.id) { preference in + let category = preference.category + Button { + DispatchQueue.main.async { + viewModel.send(.tapTodoCategory(category)) + } + } label: { + labelImage( + text: category.localizedName, + systemName: category.symbolName, + imageColor: category.color + ) } - } label: { - labelImage( - text: category.localizedName, - systemName: category.symbolName, - imageColor: category.color - ) } } } header: { From 328883c8644e8c0b413f92048b54bee6b0861acf Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 30 Mar 2026 14:04:59 +0900 Subject: [PATCH 10/29] =?UTF-8?q?feat:=20=EC=B9=B4=ED=85=8C=EA=B3=A0?= =?UTF-8?q?=EB=A6=AC=EB=A5=BC=20=EC=A0=9C=EA=B1=B0=20=EC=8B=9C=20=ED=95=B4?= =?UTF-8?q?=EB=8B=B9=20=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC=EB=A5=BC=20?= =?UTF-8?q?=EC=84=A0=ED=83=9D=ED=95=9C=20TODO=EB=A5=BC=20=EA=B8=B0?= =?UTF-8?q?=ED=83=80=20=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC=EB=A1=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=ED=95=98=EB=8A=94=20Cloud=20Functions=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Firebase/functions/src/index.ts | 7 + Firebase/functions/src/todoCategory/update.ts | 174 ++++++++++++++++++ 2 files changed, 181 insertions(+) create mode 100644 Firebase/functions/src/todoCategory/update.ts diff --git a/Firebase/functions/src/index.ts b/Firebase/functions/src/index.ts index fb12553b..308fe070 100644 --- a/Firebase/functions/src/index.ts +++ b/Firebase/functions/src/index.ts @@ -38,6 +38,11 @@ import { removeStaleTodoReceipts } from "./todo/remove"; +import { + requestMoveRemovedCategoryTodosToEtc, + completeMoveRemovedCategoryTodosToEtc +} from "./todoCategory/update"; + import { requestTodoDeletion, undoTodoDeletion, @@ -96,6 +101,8 @@ export { removeTodoNotificationDocuments, removeCompletedTodoReceipts, removeStaleTodoReceipts, + requestMoveRemovedCategoryTodosToEtc, + completeMoveRemovedCategoryTodosToEtc, requestTodoDeletion, undoTodoDeletion, completeTodoDeletion, diff --git a/Firebase/functions/src/todoCategory/update.ts b/Firebase/functions/src/todoCategory/update.ts new file mode 100644 index 00000000..491c9ab0 --- /dev/null +++ b/Firebase/functions/src/todoCategory/update.ts @@ -0,0 +1,174 @@ +import { onDocumentUpdated } from "firebase-functions/v2/firestore"; +import { onTaskDispatched } from "firebase-functions/v2/tasks"; +import { getFunctions } from "firebase-admin/functions"; +import * as admin from "firebase-admin"; +import * as logger from "firebase-functions/logger"; +import { normalizeError } from "../common/error"; + +const LOCATION = "asia-northeast3"; +const BATCH_SIZE = 200; +const ETC_CATEGORY = "etc"; + +type CategoryItem = { + kind?: unknown; + name?: unknown; +}; + +type TodoCategoryUpdateTaskData = { + userId: string; + categoryName: string; + createdAt?: FirebaseFirestore.Timestamp | Date | null; +}; + +export const requestMoveRemovedCategoryTodosToEtc = onDocumentUpdated({ + document: "users/{userId}/userData/categories", + region: LOCATION + }, + async (event) => { + const userId = event.params.userId; + const beforeData = event.data?.before.data(); + const afterData = event.data?.after.data(); + + if (!beforeData || !afterData) { return; } + + const beforeItems = Array.isArray(beforeData.items) ? beforeData.items as CategoryItem[] : []; + const afterItems = Array.isArray(afterData.items) ? afterData.items as CategoryItem[] : []; + const removedNames = getRemovedNames(beforeItems, afterItems); + + if (removedNames.length === 0) { return; } + + try { + const queue = getFunctions().taskQueue( + `locations/${LOCATION}/functions/completeMoveRemovedCategoryTodosToEtc` + ); + + for (const categoryName of removedNames) { + const taskRef = admin.firestore().collection("todoCategoryUpdateTasks").doc(); + const taskData = { + userId, + categoryName, + createdAt: admin.firestore.FieldValue.serverTimestamp() + }; + + try { + await taskRef.set(taskData); + await queue.enqueue({ taskId: taskRef.id }); + } catch (error) { + try { + await taskRef.delete(); + } catch (cleanupError) { + logger.warn("todoCategoryUpdateTasks 정리 실패", { + userId, + categoryName, + taskId: taskRef.id, + error: normalizeError(cleanupError) + }); + } + + throw error; + } + } + } catch (error) { + logger.error("삭제된 사용자 카테고리 todo 정리 요청 실패", { + userId, + removedNames, + error: normalizeError(error) + }); + throw error; + } + } +); + +export const completeMoveRemovedCategoryTodosToEtc = onTaskDispatched({ + region: LOCATION, + retryConfig: { maxAttempts: 3, minBackoffSeconds: 5 }, + rateLimits: { maxDispatchesPerSecond: 20 }, + }, + async (request) => { + const taskId = typeof request.data?.taskId === "string" ? request.data.taskId.trim() : ""; + if (!taskId) { + logger.warn("유효하지 않은 카테고리 정리 payload", request.data); + return; + } + + const taskRef = admin.firestore().collection("todoCategoryUpdateTasks").doc(taskId); + const taskSnapshot = await taskRef.get(); + if (!taskSnapshot.exists) { return; } + + const taskData = taskSnapshot.data() as TodoCategoryUpdateTaskData | undefined; + const userId = typeof taskData?.userId === "string" ? taskData.userId : ""; + const categoryName = typeof taskData?.categoryName === "string" ? taskData.categoryName : ""; + + if (!userId || !categoryName) { + logger.warn("todoCategoryUpdateTasks 문서 형식이 올바르지 않습니다.", { taskId }); + return; + } + + try { + await updateTodos(userId, categoryName); + await taskRef.delete(); + } catch (error) { + logger.error("삭제된 사용자 카테고리 todo 정리 실패", { + userId, + categoryName, + taskId, + error: normalizeError(error) + }); + throw error; + } + } +); + +function getRemovedNames( + beforeItems: CategoryItem[], + afterItems: CategoryItem[] +): string[] { + const beforeNames = new Set( + beforeItems.flatMap((item) => { + if (item.kind !== "user") { return []; } + return typeof item.name === "string" ? [item.name] : []; + }) + ); + const afterNames = new Set( + afterItems.flatMap((item) => { + if (item.kind !== "user") { return []; } + return typeof item.name === "string" ? [item.name] : []; + }) + ); + + return Array.from(beforeNames).filter((name) => !afterNames.has(name)); +} + +async function updateTodos( + userId: string, + categoryName: string +): Promise { + let lastDocument: + FirebaseFirestore.QueryDocumentSnapshot | undefined; + + while (true) { + let query = admin.firestore() + .collection(`users/${userId}/todoLists`) + .where("category", "==", categoryName) + .orderBy(admin.firestore.FieldPath.documentId()) + .limit(BATCH_SIZE); + + if (lastDocument) { + query = query.startAfter(lastDocument); + } + + const querySnapshot = await query.get(); + if (querySnapshot.empty) { return; } + + const batch = admin.firestore().batch(); + querySnapshot.docs.forEach((document) => { + batch.update(document.ref, { + category: ETC_CATEGORY + }); + }); + await batch.commit(); + + if (querySnapshot.size < BATCH_SIZE) { return; } + lastDocument = querySnapshot.docs[querySnapshot.docs.length - 1]; + } +} From b4c1e13e2c4385e2dff7dc33d5577634d32031f1 Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 30 Mar 2026 14:17:14 +0900 Subject: [PATCH 11/29] =?UTF-8?q?style:=20=EC=9D=B4=EB=A6=84=EC=9D=B4=20?= =?UTF-8?q?=EC=96=B4=EB=A0=A4=EC=9A=B4=20=EB=A9=94=EC=84=9C=EB=93=9C?= =?UTF-8?q?=EB=AA=85=EC=9D=84=20=EC=A7=81=EA=B4=80=EC=A0=81=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Firebase/functions/src/index.ts | 10 +++++----- Firebase/functions/src/todo/{remove.ts => cleanup.ts} | 10 +++++----- 2 files changed, 10 insertions(+), 10 deletions(-) rename Firebase/functions/src/todo/{remove.ts => cleanup.ts} (92%) diff --git a/Firebase/functions/src/index.ts b/Firebase/functions/src/index.ts index 308fe070..a8a08a82 100644 --- a/Firebase/functions/src/index.ts +++ b/Firebase/functions/src/index.ts @@ -34,9 +34,9 @@ import { import { removeTodoNotificationDocuments, - removeCompletedTodoReceipts, - removeStaleTodoReceipts -} from "./todo/remove"; + removeCompletedTodoNotificationRecords, + cleanupUnusedTodoNotificationRecords +} from "./todo/cleanup"; import { requestMoveRemovedCategoryTodosToEtc, @@ -99,8 +99,8 @@ export { export { removeTodoNotificationDocuments, - removeCompletedTodoReceipts, - removeStaleTodoReceipts, + removeCompletedTodoNotificationRecords, + cleanupUnusedTodoNotificationRecords, requestMoveRemovedCategoryTodosToEtc, completeMoveRemovedCategoryTodosToEtc, requestTodoDeletion, diff --git a/Firebase/functions/src/todo/remove.ts b/Firebase/functions/src/todo/cleanup.ts similarity index 92% rename from Firebase/functions/src/todo/remove.ts rename to Firebase/functions/src/todo/cleanup.ts index 45745ded..881a0173 100644 --- a/Firebase/functions/src/todo/remove.ts +++ b/Firebase/functions/src/todo/cleanup.ts @@ -28,7 +28,7 @@ export const removeTodoNotificationDocuments = onDocumentDeleted({ } ); -export const removeCompletedTodoReceipts = onDocumentUpdated({ +export const removeCompletedTodoNotificationRecords = onDocumentUpdated({ document: "users/{userId}/todoLists/{todoId}", region: LOCATION }, @@ -54,7 +54,7 @@ export const removeCompletedTodoReceipts = onDocumentUpdated({ try { await deleteByTodoId(userId, "notificationReceipts", todoId); } catch (error) { - logger.error("완료된 todo의 notificationReceipts 정리 실패", { + logger.error("완료된 todo의 notification record 정리 실패", { userId, todoId, error @@ -63,7 +63,7 @@ export const removeCompletedTodoReceipts = onDocumentUpdated({ } ); -export const removeStaleTodoReceipts = onSchedule({ +export const cleanupUnusedTodoNotificationRecords = onSchedule({ region: LOCATION, schedule: "0 * * * *", timeZone: "UTC" @@ -99,7 +99,7 @@ export const removeStaleTodoReceipts = onSchedule({ lastExpiredCompletedTodo = snapshot.docs[snapshot.docs.length - 1]; } } catch (error) { - logger.error("지난 마감일의 완료된 todo receipt 정리 실패", { error }); + logger.error("지난 마감일의 완료된 todo notification record 정리 실패", { error }); } try { @@ -131,7 +131,7 @@ export const removeStaleTodoReceipts = onSchedule({ lastTodoWithoutDueDate = snapshot.docs[snapshot.docs.length - 1]; } } catch (error) { - logger.error("마감일이 없는 todo receipt 정리 실패", { error }); + logger.error("마감일이 없는 todo notification record 정리 실패", { error }); } } ); From 8f38d90d080740e0d464cda3481362060c89982e Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 30 Mar 2026 14:44:24 +0900 Subject: [PATCH 12/29] =?UTF-8?q?fix:=20=EC=BB=A4=EC=8A=A4=ED=85=80=20?= =?UTF-8?q?=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC=EA=B0=80=20=EB=94=94?= =?UTF-8?q?=EC=BD=94=EB=94=A9=20=EB=90=98=EC=A7=80=20=EC=95=8A=EB=8A=94=20?= =?UTF-8?q?=ED=98=84=EC=83=81=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DevLog/App/Assembler/DataAssembler.swift | 3 +- DevLog/Data/DTO/TodoDTO.swift | 7 +- DevLog/Data/Mapper/TodoMapping.swift | 14 ++- .../Data/Repository/TodoRepositoryImpl.swift | 86 +++++++++++++++++-- DevLog/Infra/Service/TodoService.swift | 18 +++- 5 files changed, 114 insertions(+), 14 deletions(-) diff --git a/DevLog/App/Assembler/DataAssembler.swift b/DevLog/App/Assembler/DataAssembler.swift index 6419c724..86f8d3d3 100644 --- a/DevLog/App/Assembler/DataAssembler.swift +++ b/DevLog/App/Assembler/DataAssembler.swift @@ -28,7 +28,8 @@ final class DataAssembler: Assembler { container.register(TodoRepository.self) { TodoRepositoryImpl( - todoService: container.resolve(TodoService.self) + todoService: container.resolve(TodoService.self), + todoCategoryService: container.resolve(TodoCategoryService.self) ) } diff --git a/DevLog/Data/DTO/TodoDTO.swift b/DevLog/Data/DTO/TodoDTO.swift index 0ed3b772..26dc5ff3 100644 --- a/DevLog/Data/DTO/TodoDTO.swift +++ b/DevLog/Data/DTO/TodoDTO.swift @@ -7,6 +7,11 @@ import Foundation +enum TodoCategoryResponse { + case raw(String) + case decoded(TodoCategory) +} + struct TodoRequest: Encodable { let id: String let isPinned: Bool @@ -36,5 +41,5 @@ struct TodoResponse { let completedAt: Date? let dueDate: Date? let tags: [String] - let category: String + let category: TodoCategoryResponse } diff --git a/DevLog/Data/Mapper/TodoMapping.swift b/DevLog/Data/Mapper/TodoMapping.swift index d810d90c..02b3e840 100644 --- a/DevLog/Data/Mapper/TodoMapping.swift +++ b/DevLog/Data/Mapper/TodoMapping.swift @@ -27,8 +27,16 @@ extension TodoRequest { extension TodoResponse { func toDomain() throws -> Todo { - guard let category = SystemTodoCategory(rawValue: self.category) else { - throw DataError.invalidData("TodoResponse.category is invalid: \(self.category)") + let todoCategory: TodoCategory + + switch category { + case .decoded(let category): + todoCategory = category + case .raw(let category): + guard let systemTodoCategory = SystemTodoCategory(rawValue: category) else { + throw DataError.invalidData("TodoResponse.category is invalid: \(category)") + } + todoCategory = .system(systemTodoCategory) } return Todo( @@ -44,7 +52,7 @@ extension TodoResponse { completedAt: self.completedAt, dueDate: self.dueDate, tags: self.tags, - category: .system(category) + category: todoCategory ) } } diff --git a/DevLog/Data/Repository/TodoRepositoryImpl.swift b/DevLog/Data/Repository/TodoRepositoryImpl.swift index 0d63c573..64c8be4b 100644 --- a/DevLog/Data/Repository/TodoRepositoryImpl.swift +++ b/DevLog/Data/Repository/TodoRepositoryImpl.swift @@ -9,20 +9,54 @@ import Foundation final class TodoRepositoryImpl: TodoRepository { private let todoService: TodoService + private let todoCategoryService: TodoCategoryService - init(todoService: TodoService) { + init( + todoService: TodoService, + todoCategoryService: TodoCategoryService + ) { self.todoService = todoService + self.todoCategoryService = todoCategoryService } func fetchTodos(_ query: TodoQuery, cursor: TodoCursor?) async throws -> TodoPage { let responseCursor = cursor.map { TodoCursorDTO.fromDomain($0) } - let response = try await todoService.fetchTodos(query, cursor: responseCursor) - return try response.toDomain() + async let response = todoService.fetchTodos(query, cursor: responseCursor) + async let preferences = todoCategoryService.fetchPreferences() + + let (todoResponse, todoPreferences) = try await (response, preferences) + let userTodoCategories: [UserTodoCategory] = todoPreferences.compactMap { preference in + guard case .user(let category) = preference.category else { + return nil + } + + return category + } + + let resolvedTodoResponses = try todoResponse.items.map { + try resolve($0, userTodoCategories: userTodoCategories) + } + + return try TodoPageResponse( + items: resolvedTodoResponses, + nextCursor: todoResponse.nextCursor + ).toDomain() } func fetchTodo(_ todoId: String) async throws -> Todo { - let response = try await todoService.fetchTodo(todoId: todoId) - return try response.toDomain() + async let response = todoService.fetchTodo(todoId: todoId) + async let preferences = todoCategoryService.fetchPreferences() + + let (todoResponse, todoPreferences) = try await (response, preferences) + let userTodoCategories: [UserTodoCategory] = todoPreferences.compactMap { preference in + guard case .user(let category) = preference.category else { + return nil + } + + return category + } + + return try resolve(todoResponse, userTodoCategories: userTodoCategories).toDomain() } func fetchReferenceItems(_ numbers: [Int]) async throws -> [Int: TodoReferenceItem] { @@ -42,3 +76,45 @@ final class TodoRepositoryImpl: TodoRepository { try await todoService.undoDeleteTodo(todoId: todoId) } } + +private extension TodoRepositoryImpl { + func resolve( + _ response: TodoResponse, + userTodoCategories: [UserTodoCategory] + ) throws -> TodoResponse { + let categoryName: String + switch response.category { + case .raw(let value): + categoryName = value + case .decoded: + return response + } + + let category: TodoCategory + if let systemTodoCategory = SystemTodoCategory(rawValue: categoryName) { + category = .system(systemTodoCategory) + } else if let userTodoCategory = userTodoCategories.first(where: { + $0.name == categoryName + }) { + category = .user(userTodoCategory) + } else { + throw DataError.invalidData("TodoResponse.category is invalid: \(categoryName)") + } + + return TodoResponse( + id: response.id, + isPinned: response.isPinned, + isCompleted: response.isCompleted, + isChecked: response.isChecked, + number: response.number, + title: response.title, + content: response.content, + createdAt: response.createdAt, + updatedAt: response.updatedAt, + completedAt: response.completedAt, + dueDate: response.dueDate, + tags: response.tags, + category: .decoded(category) + ) + } +} diff --git a/DevLog/Infra/Service/TodoService.swift b/DevLog/Infra/Service/TodoService.swift index a576bc63..fc8ebd85 100644 --- a/DevLog/Infra/Service/TodoService.swift +++ b/DevLog/Infra/Service/TodoService.swift @@ -269,16 +269,26 @@ final class TodoService { let data = document.data() guard !(data[TodoFieldKey.deletingAt.rawValue] is Timestamp), - let response = makeResponse(from: document), - let category = SystemTodoCategory(rawValue: response.category) + let response = makeResponse(from: document) else { return } + let todoCategory: TodoCategory + switch response.category { + case .raw(let category): + guard let systemTodoCategory = SystemTodoCategory(rawValue: category) else { + return + } + todoCategory = .system(systemTodoCategory) + case .decoded(let category): + todoCategory = category + } + partialResult[response.number] = TodoReferenceItem( id: response.id, title: response.title, - category: .system(category) + category: todoCategory ) } } @@ -469,7 +479,7 @@ private extension TodoService { completedAt: completedAt, dueDate: dueDate, tags: tags, - category: category + category: .raw(category) ) } From 1ba434625d672371dd4fcfc26f082f16da52d751 Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 30 Mar 2026 14:58:18 +0900 Subject: [PATCH 13/29] =?UTF-8?q?feat:=20TODO=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?=EC=8B=9C=20=EC=BB=A4=EC=8A=A4=ED=85=80=20=EC=B9=B4=ED=85=8C?= =?UTF-8?q?=EA=B3=A0=EB=A6=AC=EB=8F=84=20=EB=96=A0=EC=9E=88=EC=9D=84=20?= =?UTF-8?q?=EC=88=98=20=EC=9E=88=EB=8F=84=EB=A1=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ViewModel/TodoEditorViewModel.swift | 21 +++++++++++++++++++ DevLog/UI/Home/HomeView.swift | 1 + DevLog/UI/Home/TodoDetailView.swift | 1 + DevLog/UI/Home/TodoEditorView.swift | 5 +++-- DevLog/UI/Home/TodoListView.swift | 1 + 5 files changed, 27 insertions(+), 2 deletions(-) diff --git a/DevLog/Presentation/ViewModel/TodoEditorViewModel.swift b/DevLog/Presentation/ViewModel/TodoEditorViewModel.swift index 22d92722..40f0dd9e 100644 --- a/DevLog/Presentation/ViewModel/TodoEditorViewModel.swift +++ b/DevLog/Presentation/ViewModel/TodoEditorViewModel.swift @@ -57,6 +57,7 @@ final class TodoEditorViewModel: Store { var tagText: String = "" var focusOnEditor: Bool = false var tabViewTag: Tag = .editor + var categories: [TodoCategory] = [] var category: TodoCategory = .system(.etc) var isValidToSave: Bool { !title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty @@ -68,6 +69,7 @@ final class TodoEditorViewModel: Store { } enum Action { + case onAppear case addTag(String) case removeTag(String) case setContent(String) @@ -80,15 +82,18 @@ final class TodoEditorViewModel: Store { case setTabViewTag(Tag) case setTagText(String) case setTitle(String) + case setCategories([TodoCategory]) case setReferenceItems([Int: TodoReferenceItem]) } enum SideEffect { + case fetchCategories case resolveMarkdown(String) } private(set) var state = State() private let calendar = Calendar.current + private let fetchPreferencesUseCase: FetchTodoCategoryPreferencesUseCase private let fetchReferenceItemsUseCase: FetchReferenceItemsUseCase private let id: String private let isCompleted: Bool @@ -117,8 +122,10 @@ final class TodoEditorViewModel: Store { // 새로운 Todo 생성용 생성자 init( category: TodoCategory, + fetchPreferencesUseCase: FetchTodoCategoryPreferencesUseCase, fetchReferenceItemsUseCase: FetchReferenceItemsUseCase ) { + self.fetchPreferencesUseCase = fetchPreferencesUseCase self.fetchReferenceItemsUseCase = fetchReferenceItemsUseCase self.id = UUID().uuidString self.isCompleted = false @@ -127,13 +134,16 @@ final class TodoEditorViewModel: Store { self.createdAt = nil self.originalDraft = nil state.category = category + state.categories = [category] } // 기존 Todo 편집용 생성자 init( todo: Todo, + fetchPreferencesUseCase: FetchTodoCategoryPreferencesUseCase, fetchReferenceItemsUseCase: FetchReferenceItemsUseCase ) { + self.fetchPreferencesUseCase = fetchPreferencesUseCase self.fetchReferenceItemsUseCase = fetchReferenceItemsUseCase self.id = todo.id self.isCompleted = todo.isCompleted @@ -156,6 +166,8 @@ final class TodoEditorViewModel: Store { var effects: [SideEffect] = [] switch action { + case .onAppear: + effects = [.fetchCategories] case .addTag(let tag): if !tag.isEmpty { state.tags.append(tag) @@ -193,6 +205,8 @@ final class TodoEditorViewModel: Store { if tag == .preview { effects = [.resolveMarkdown(state.content)] } + case .setCategories(let categories): + state.categories = categories case .setReferenceItems(let items): state.referenceItems = items } @@ -203,6 +217,13 @@ final class TodoEditorViewModel: Store { func run(_ effect: SideEffect) { switch effect { + case .fetchCategories: + Task { + do { + let preferences = try await fetchPreferencesUseCase.execute() + send(.setCategories(preferences.map(\.category))) + } catch { } + } case .resolveMarkdown(let content): Task { let numbers = content.todoReferenceNumbers diff --git a/DevLog/UI/Home/HomeView.swift b/DevLog/UI/Home/HomeView.swift index 66cca5a7..451e54ba 100644 --- a/DevLog/UI/Home/HomeView.swift +++ b/DevLog/UI/Home/HomeView.swift @@ -83,6 +83,7 @@ struct HomeView: View { TodoEditorView( viewModel: TodoEditorViewModel( category: selectedCategory, + fetchPreferencesUseCase: container.resolve(FetchTodoCategoryPreferencesUseCase.self), fetchReferenceItemsUseCase: container.resolve(FetchReferenceItemsUseCase.self) ), onSubmit: { viewModel.send(.addTodo($0)) } diff --git a/DevLog/UI/Home/TodoDetailView.swift b/DevLog/UI/Home/TodoDetailView.swift index 91020b0e..693f3ce7 100644 --- a/DevLog/UI/Home/TodoDetailView.swift +++ b/DevLog/UI/Home/TodoDetailView.swift @@ -63,6 +63,7 @@ struct TodoDetailView: View { TodoEditorView( viewModel: TodoEditorViewModel( todo: todo, + fetchPreferencesUseCase: container.resolve(FetchTodoCategoryPreferencesUseCase.self), fetchReferenceItemsUseCase: container.resolve(FetchReferenceItemsUseCase.self) ), onSubmit: { viewModel.send(.upsertTodo($0)) } diff --git a/DevLog/UI/Home/TodoEditorView.swift b/DevLog/UI/Home/TodoEditorView.swift index df1dc831..7a2f1457 100644 --- a/DevLog/UI/Home/TodoEditorView.swift +++ b/DevLog/UI/Home/TodoEditorView.swift @@ -38,6 +38,7 @@ struct TodoEditorView: View { .onTapGesture { field = .content } + .onAppear { viewModel.send(.onAppear) } .navigationTitle(viewModel.navigationTitle) .navigationBarTitleDisplayMode(.inline) .toolbarBackground(.background, for: .navigationBar) @@ -215,9 +216,9 @@ private struct TodoEditorInfoSheetView: View { set: { viewModel.send(.setCategory($0)) } ) ) { - ForEach(SystemTodoCategory.allCases) { category in + ForEach(viewModel.state.categories, id: \.id) { category in Text(category.localizedName) - .tag(TodoCategory.system(category)) + .tag(category) } } diff --git a/DevLog/UI/Home/TodoListView.swift b/DevLog/UI/Home/TodoListView.swift index 3bcd904b..b98bcdc9 100644 --- a/DevLog/UI/Home/TodoListView.swift +++ b/DevLog/UI/Home/TodoListView.swift @@ -84,6 +84,7 @@ struct TodoListView: View { TodoEditorView( viewModel: TodoEditorViewModel( category: viewModel.state.category, + fetchPreferencesUseCase: container.resolve(FetchTodoCategoryPreferencesUseCase.self), fetchReferenceItemsUseCase: container.resolve(FetchReferenceItemsUseCase.self) ), onSubmit: { viewModel.send(.upsertTodo($0)) } From 10b5d13edb5d78078732f80a20de1ab0d4838f85 Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 30 Mar 2026 15:09:07 +0900 Subject: [PATCH 14/29] =?UTF-8?q?fix:=20=ED=91=B8=EC=8B=9C=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=20=EB=8D=B0=EC=9D=B4=ED=84=B0=EA=B0=80=20=EC=8B=9C?= =?UTF-8?q?=EC=8A=A4=ED=85=9C=20=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC?= =?UTF-8?q?=EB=A7=8C=20=EB=94=94=EC=BD=94=EB=94=A9=20=EA=B0=80=EB=8A=A5?= =?UTF-8?q?=ED=95=9C=20=ED=98=84=EC=83=81=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DevLog/App/Assembler/DataAssembler.swift | 5 +- .../Data/DTO/PushNotificationResponse.swift | 2 +- DevLog/Data/DTO/TodoCategoryResponse.swift | 13 ++ DevLog/Data/DTO/TodoDTO.swift | 5 - .../Data/Mapper/PushNotificationMapping.swift | 15 +- .../PushNotificationRepositoryImpl.swift | 131 ++++++++++++++++-- .../Service/PushNotificationService.swift | 2 +- 7 files changed, 148 insertions(+), 25 deletions(-) create mode 100644 DevLog/Data/DTO/TodoCategoryResponse.swift diff --git a/DevLog/App/Assembler/DataAssembler.swift b/DevLog/App/Assembler/DataAssembler.swift index 86f8d3d3..b193137a 100644 --- a/DevLog/App/Assembler/DataAssembler.swift +++ b/DevLog/App/Assembler/DataAssembler.swift @@ -74,7 +74,10 @@ final class DataAssembler: Assembler { } container.register(PushNotificationRepository.self) { - PushNotificationRepositoryImpl(pushNotificationService: container.resolve(PushNotificationService.self)) + PushNotificationRepositoryImpl( + pushNotificationService: container.resolve(PushNotificationService.self), + todoCategoryService: container.resolve(TodoCategoryService.self) + ) } container.register(WebPageRepository.self) { diff --git a/DevLog/Data/DTO/PushNotificationResponse.swift b/DevLog/Data/DTO/PushNotificationResponse.swift index 9fd5a1ac..f72e9d51 100644 --- a/DevLog/Data/DTO/PushNotificationResponse.swift +++ b/DevLog/Data/DTO/PushNotificationResponse.swift @@ -14,5 +14,5 @@ struct PushNotificationResponse { let receivedAt: Date let isRead: Bool let todoId: String - let todoCategory: String + let todoCategory: TodoCategoryResponse } diff --git a/DevLog/Data/DTO/TodoCategoryResponse.swift b/DevLog/Data/DTO/TodoCategoryResponse.swift new file mode 100644 index 00000000..75b841f1 --- /dev/null +++ b/DevLog/Data/DTO/TodoCategoryResponse.swift @@ -0,0 +1,13 @@ +// +// TodoCategoryResponse.swift +// DevLog +// +// Created by opfic on 3/30/26. +// + +import Foundation + +enum TodoCategoryResponse { + case raw(String) + case decoded(TodoCategory) +} diff --git a/DevLog/Data/DTO/TodoDTO.swift b/DevLog/Data/DTO/TodoDTO.swift index 26dc5ff3..2d8e16e8 100644 --- a/DevLog/Data/DTO/TodoDTO.swift +++ b/DevLog/Data/DTO/TodoDTO.swift @@ -7,11 +7,6 @@ import Foundation -enum TodoCategoryResponse { - case raw(String) - case decoded(TodoCategory) -} - struct TodoRequest: Encodable { let id: String let isPinned: Bool diff --git a/DevLog/Data/Mapper/PushNotificationMapping.swift b/DevLog/Data/Mapper/PushNotificationMapping.swift index 7861954d..dfb58a0a 100644 --- a/DevLog/Data/Mapper/PushNotificationMapping.swift +++ b/DevLog/Data/Mapper/PushNotificationMapping.swift @@ -7,8 +7,17 @@ extension PushNotificationResponse { func toDomain() throws -> PushNotification { - guard let todoCategory = SystemTodoCategory(rawValue: self.todoCategory) else { - throw DataError.invalidData("PushNotificationResponse.todoCategory is invalid: \(self.todoCategory)") + let todoCategory: TodoCategory + + switch self.todoCategory { + case .decoded(let category): + todoCategory = category + case .raw(let category): + guard let systemTodoCategory = SystemTodoCategory(rawValue: category) else { + throw DataError.invalidData("PushNotificationResponse.todoCategory is invalid: \(category)") + } + + todoCategory = .system(systemTodoCategory) } return PushNotification( @@ -18,7 +27,7 @@ extension PushNotificationResponse { receivedAt: self.receivedAt, isRead: self.isRead, todoId: self.todoId, - todoCategory: .system(todoCategory) + todoCategory: todoCategory ) } } diff --git a/DevLog/Data/Repository/PushNotificationRepositoryImpl.swift b/DevLog/Data/Repository/PushNotificationRepositoryImpl.swift index 34826bac..ad4bca6d 100644 --- a/DevLog/Data/Repository/PushNotificationRepositoryImpl.swift +++ b/DevLog/Data/Repository/PushNotificationRepositoryImpl.swift @@ -9,25 +9,30 @@ import Foundation import Combine final class PushNotificationRepositoryImpl: PushNotificationRepository { - private let service: PushNotificationService + private let pushNotificationService: PushNotificationService + private let todoCategoryService: TodoCategoryService - init(pushNotificationService: PushNotificationService) { - self.service = pushNotificationService + init( + pushNotificationService: PushNotificationService, + todoCategoryService: TodoCategoryService + ) { + self.pushNotificationService = pushNotificationService + self.todoCategoryService = todoCategoryService } /// 푸시 알림 On/Off 설정 func fetchPushNotificationEnabled() async throws -> Bool { - return try await service.fetchPushNotificationEnabled() + return try await pushNotificationService.fetchPushNotificationEnabled() } /// 푸시 알림 시간 설정 func fetchPushNotificationTime() async throws -> DateComponents { - return try await service.fetchPushNotificationTime() + return try await pushNotificationService.fetchPushNotificationTime() } /// 푸시 알림 설정 업데이트 func updatePushNotificationSettings(_ settings: PushNotificationSettings) async throws { - try await service.updatePushNotificationSettings( + try await pushNotificationService.updatePushNotificationSettings( isEnabled: settings.isEnabled, components: settings.scheduledTime ) } @@ -38,35 +43,133 @@ final class PushNotificationRepositoryImpl: PushNotificationRepository { cursor: PushNotificationCursor? ) async throws -> PushNotificationPage { let cursorDTO = cursor.map { PushNotificationCursorDTO.fromDomain($0) } - let response = try await service.requestNotifications(query, cursor: cursorDTO) - return try response.toDomain() + async let responseTask = pushNotificationService.requestNotifications(query, cursor: cursorDTO) + async let preferencesTask = todoCategoryService.fetchPreferences() + + let (response, preferences) = try await (responseTask, preferencesTask) + let userTodoCategories: [UserTodoCategory] = preferences.compactMap { preference in + guard case .user(let userTodoCategory) = preference.category else { + return nil + } + + return userTodoCategory + } + + let responses = try response.items.map { + try resolve($0, userTodoCategories: userTodoCategories) + } + + return try PushNotificationPageResponse( + items: responses, + nextCursor: response.nextCursor + ).toDomain() } func observeNotifications( _ query: PushNotificationQuery, limit: Int ) throws -> AnyPublisher { - try service.observeNotifications(query, limit: limit) - .tryMap { try $0.toDomain() } + let subject = PassthroughSubject() + var cancellable: AnyCancellable? + + cancellable = try pushNotificationService.observeNotifications(query, limit: limit) + .sink( + receiveCompletion: { completion in + switch completion { + case .finished: + subject.send(completion: .finished) + case .failure(let error): + subject.send(completion: .failure(error)) + } + }, + receiveValue: { [weak self] response in + guard let self else { return } + + Task { + do { + let preferences = try await self.todoCategoryService.fetchPreferences() + let userTodoCategories: [UserTodoCategory] = preferences.compactMap { preference in + guard case .user(let userTodoCategory) = preference.category else { + return nil + } + + return userTodoCategory + } + + let responses = try response.items.map { + try self.resolve($0, userTodoCategories: userTodoCategories) + } + + let page = try PushNotificationPageResponse( + items: responses, + nextCursor: response.nextCursor + ).toDomain() + + subject.send(page) + } catch { + subject.send(completion: .failure(error)) + } + } + } + ) + + return subject + .handleEvents(receiveCancel: { cancellable?.cancel() }) .eraseToAnyPublisher() } func observeUnreadPushCount() throws -> AnyPublisher { - try service.observeUnreadPushCount() + try pushNotificationService.observeUnreadPushCount() .eraseToAnyPublisher() } // 푸시 알림 기록 삭제 func deleteNotification(_ notificationID: String) async throws { - try await service.deleteNotification(notificationID) + try await pushNotificationService.deleteNotification(notificationID) } func undoDeleteNotification(_ notificationID: String) async throws { - try await service.undoDeleteNotification(notificationID) + try await pushNotificationService.undoDeleteNotification(notificationID) } // 푸시 알림 읽음/안읽음 토글 func toggleNotificationRead(_ todoId: String) async throws { - try await service.toggleNotificationRead(todoId) + try await pushNotificationService.toggleNotificationRead(todoId) + } +} + +private extension PushNotificationRepositoryImpl { + func resolve( + _ response: PushNotificationResponse, + userTodoCategories: [UserTodoCategory] + ) throws -> PushNotificationResponse { + let categoryName: String + switch response.todoCategory { + case .raw(let rawValue): + categoryName = rawValue + case .decoded: + return response + } + + let todoCategory: TodoCategory + if let systemTodoCategory = SystemTodoCategory(rawValue: categoryName) { + todoCategory = .system(systemTodoCategory) + } else if let userTodoCategory = userTodoCategories.first(where: { + $0.name == categoryName + }) { + todoCategory = .user(userTodoCategory) + } else { + throw DataError.invalidData("PushNotificationResponse.todoCategory is invalid: \(categoryName)") + } + + return PushNotificationResponse( + id: response.id, + title: response.title, + body: response.body, + receivedAt: response.receivedAt, + isRead: response.isRead, + todoId: response.todoId, + todoCategory: .decoded(todoCategory) + ) } } diff --git a/DevLog/Infra/Service/PushNotificationService.swift b/DevLog/Infra/Service/PushNotificationService.swift index f8da35cf..c3c74d68 100644 --- a/DevLog/Infra/Service/PushNotificationService.swift +++ b/DevLog/Infra/Service/PushNotificationService.swift @@ -318,7 +318,7 @@ private extension PushNotificationService { receivedAt: receivedAt.dateValue(), isRead: isRead, todoId: todoId, - todoCategory: todoCategory + todoCategory: .raw(todoCategory) ) } From 7f81c35e42d78ade773862758b37be1279d43295 Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 30 Mar 2026 15:22:13 +0900 Subject: [PATCH 15/29] =?UTF-8?q?feat:=20TODO=EC=9D=98=20=EC=B9=B4?= =?UTF-8?q?=ED=85=8C=EA=B3=A0=EB=A6=AC=EB=A5=BC=20=EB=B3=80=EA=B2=BD?= =?UTF-8?q?=ED=95=98=EB=A9=B4=20=EA=B7=B8=EC=97=90=20=EB=8C=80=EC=9D=91?= =?UTF-8?q?=EB=90=98=EB=8A=94=20=ED=91=B8=EC=8B=9C=20=EC=95=8C=EB=A6=BC?= =?UTF-8?q?=EC=9D=98=20=EB=8D=B0=EC=9D=B4=ED=84=B0=EB=8F=84=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=EB=90=98=EB=8F=84=EB=A1=9D=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Firebase/functions/src/fcm/notification.ts | 4 +- Firebase/functions/src/index.ts | 5 + Firebase/functions/src/todo/update.ts | 109 +++++++++++++++++++++ 3 files changed, 117 insertions(+), 1 deletion(-) create mode 100644 Firebase/functions/src/todo/update.ts diff --git a/Firebase/functions/src/fcm/notification.ts b/Firebase/functions/src/fcm/notification.ts index 167d82a7..279da8ac 100644 --- a/Firebase/functions/src/fcm/notification.ts +++ b/Firebase/functions/src/fcm/notification.ts @@ -42,7 +42,7 @@ export const sendPushNotification = onTaskDispatched({ logger.warn("notificationTask 문서 형식이 올바르지 않습니다.", { taskId }); return; } - const { userId, todoId, todoCategory, dueDateKey, title, body } = parsed; + const { userId, todoId, dueDateKey, title, body } = parsed; const settingsDocRef = admin.firestore().doc(`users/${userId}/userData/settings`); const todoDocRef = admin.firestore().doc(`users/${userId}/todoLists/${todoId}`); @@ -56,6 +56,8 @@ export const sendPushNotification = onTaskDispatched({ const todoData = todoDoc.data(); if (!todoDoc.exists || !todoData || todoData.isCompleted === true) { return; } + const todoCategory = typeof todoData.category === "string" ? todoData.category.trim() : ""; + if (!todoCategory) { return; } const timeZone = resolveTimeZone(settingsData); diff --git a/Firebase/functions/src/index.ts b/Firebase/functions/src/index.ts index a8a08a82..bc543223 100644 --- a/Firebase/functions/src/index.ts +++ b/Firebase/functions/src/index.ts @@ -38,6 +38,10 @@ import { cleanupUnusedTodoNotificationRecords } from "./todo/cleanup"; +import { + syncTodoNotificationCategory +} from "./todo/update"; + import { requestMoveRemovedCategoryTodosToEtc, completeMoveRemovedCategoryTodosToEtc @@ -101,6 +105,7 @@ export { removeTodoNotificationDocuments, removeCompletedTodoNotificationRecords, cleanupUnusedTodoNotificationRecords, + syncTodoNotificationCategory, requestMoveRemovedCategoryTodosToEtc, completeMoveRemovedCategoryTodosToEtc, requestTodoDeletion, diff --git a/Firebase/functions/src/todo/update.ts b/Firebase/functions/src/todo/update.ts new file mode 100644 index 00000000..28cc6d49 --- /dev/null +++ b/Firebase/functions/src/todo/update.ts @@ -0,0 +1,109 @@ +import { onDocumentUpdated } from "firebase-functions/v2/firestore"; +import * as admin from "firebase-admin"; +import * as logger from "firebase-functions/logger"; +import { normalizeError } from "../common/error"; + +const LOCATION = "asia-northeast3"; +const BATCH_SIZE = 200; + +export const syncTodoNotificationCategory = onDocumentUpdated({ + document: "users/{userId}/todoLists/{todoId}", + region: LOCATION + }, + async (event) => { + const beforeData = event.data?.before.data(); + const afterData = event.data?.after.data(); + const userId = event.params.userId; + const todoId = event.params.todoId; + + const beforeCategory = typeof beforeData?.category === "string" ? beforeData.category.trim() : ""; + const afterCategory = typeof afterData?.category === "string" ? afterData.category.trim() : ""; + + if (!beforeCategory || !afterCategory || beforeCategory == afterCategory) { + return; + } + + try { + await Promise.all([ + updateNotifications(userId, todoId, afterCategory), + updateNotificationTasks(userId, todoId, afterCategory) + ]); + } catch (error) { + logger.error("todo 카테고리 변경 후 알림 데이터 동기화 실패", { + userId, + todoId, + beforeCategory, + afterCategory, + error: normalizeError(error) + }); + throw error; + } + } +); + +async function updateNotifications( + userId: string, + todoId: string, + todoCategory: string +): Promise { + let lastDocument: + FirebaseFirestore.QueryDocumentSnapshot | undefined; + + while (true) { + let query = admin.firestore() + .collection(`users/${userId}/notifications`) + .where("todoId", "==", todoId) + .orderBy(admin.firestore.FieldPath.documentId()) + .limit(BATCH_SIZE); + + if (lastDocument) { + query = query.startAfter(lastDocument); + } + + const snapshot = await query.get(); + if (snapshot.empty) { return; } + + const batch = admin.firestore().batch(); + snapshot.docs.forEach((document) => { + batch.update(document.ref, { todoCategory }); + }); + await batch.commit(); + + if (snapshot.size < BATCH_SIZE) { return; } + lastDocument = snapshot.docs[snapshot.docs.length - 1]; + } +} + +async function updateNotificationTasks( + userId: string, + todoId: string, + todoCategory: string +): Promise { + let lastDocument: + FirebaseFirestore.QueryDocumentSnapshot | undefined; + + while (true) { + let query = admin.firestore() + .collection("notificationTasks") + .where("userId", "==", userId) + .where("todoId", "==", todoId) + .orderBy(admin.firestore.FieldPath.documentId()) + .limit(BATCH_SIZE); + + if (lastDocument) { + query = query.startAfter(lastDocument); + } + + const snapshot = await query.get(); + if (snapshot.empty) { return; } + + const batch = admin.firestore().batch(); + snapshot.docs.forEach((document) => { + batch.update(document.ref, { todoCategory }); + }); + await batch.commit(); + + if (snapshot.size < BATCH_SIZE) { return; } + lastDocument = snapshot.docs[snapshot.docs.length - 1]; + } +} From e08f89bc3fa26dafaf1a172b63b676c98b11da3e Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 30 Mar 2026 17:15:52 +0900 Subject: [PATCH 16/29] =?UTF-8?q?refactor:=20=EC=BB=A4=EC=8A=A4=ED=85=80?= =?UTF-8?q?=20=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC=EB=A5=BC=20id=EB=A1=9C?= =?UTF-8?q?=20=EA=B4=80=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../PushNotificationRepositoryImpl.swift | 10 +- .../Data/Repository/TodoRepositoryImpl.swift | 10 +- DevLog/Domain/Entity/TodoCategory.swift | 2 +- DevLog/Domain/Entity/UserTodoCategory.swift | 1 + .../Infra/Service/TodoCategoryService.swift | 4 + .../UserTodoCategory+Presentation.swift | 6 +- .../ViewModel/TodoManageViewModel.swift | 1 + Firebase/functions/src/todo/update.ts | 120 +++++++++++------- Firebase/functions/src/todoCategory/update.ts | 95 +++++++------- 9 files changed, 142 insertions(+), 107 deletions(-) diff --git a/DevLog/Data/Repository/PushNotificationRepositoryImpl.swift b/DevLog/Data/Repository/PushNotificationRepositoryImpl.swift index ad4bca6d..0e388c41 100644 --- a/DevLog/Data/Repository/PushNotificationRepositoryImpl.swift +++ b/DevLog/Data/Repository/PushNotificationRepositoryImpl.swift @@ -143,23 +143,23 @@ private extension PushNotificationRepositoryImpl { _ response: PushNotificationResponse, userTodoCategories: [UserTodoCategory] ) throws -> PushNotificationResponse { - let categoryName: String + let id: String switch response.todoCategory { case .raw(let rawValue): - categoryName = rawValue + id = rawValue case .decoded: return response } let todoCategory: TodoCategory - if let systemTodoCategory = SystemTodoCategory(rawValue: categoryName) { + if let systemTodoCategory = SystemTodoCategory(rawValue: id) { todoCategory = .system(systemTodoCategory) } else if let userTodoCategory = userTodoCategories.first(where: { - $0.name == categoryName + $0.id == id }) { todoCategory = .user(userTodoCategory) } else { - throw DataError.invalidData("PushNotificationResponse.todoCategory is invalid: \(categoryName)") + throw DataError.invalidData("PushNotificationResponse.todoCategory is invalid: \(id)") } return PushNotificationResponse( diff --git a/DevLog/Data/Repository/TodoRepositoryImpl.swift b/DevLog/Data/Repository/TodoRepositoryImpl.swift index 64c8be4b..d26ef956 100644 --- a/DevLog/Data/Repository/TodoRepositoryImpl.swift +++ b/DevLog/Data/Repository/TodoRepositoryImpl.swift @@ -82,23 +82,23 @@ private extension TodoRepositoryImpl { _ response: TodoResponse, userTodoCategories: [UserTodoCategory] ) throws -> TodoResponse { - let categoryName: String + let id: String switch response.category { case .raw(let value): - categoryName = value + id = value case .decoded: return response } let category: TodoCategory - if let systemTodoCategory = SystemTodoCategory(rawValue: categoryName) { + if let systemTodoCategory = SystemTodoCategory(rawValue: id) { category = .system(systemTodoCategory) } else if let userTodoCategory = userTodoCategories.first(where: { - $0.name == categoryName + $0.id == id }) { category = .user(userTodoCategory) } else { - throw DataError.invalidData("TodoResponse.category is invalid: \(categoryName)") + throw DataError.invalidData("TodoResponse.category is invalid: \(id)") } return TodoResponse( diff --git a/DevLog/Domain/Entity/TodoCategory.swift b/DevLog/Domain/Entity/TodoCategory.swift index f89c1e26..a914869a 100644 --- a/DevLog/Domain/Entity/TodoCategory.swift +++ b/DevLog/Domain/Entity/TodoCategory.swift @@ -16,7 +16,7 @@ enum TodoCategory: Equatable { case .system(let category): return category.rawValue case .user(let category): - return category.name + return category.id } } } diff --git a/DevLog/Domain/Entity/UserTodoCategory.swift b/DevLog/Domain/Entity/UserTodoCategory.swift index d5f0979a..f35ff6e0 100644 --- a/DevLog/Domain/Entity/UserTodoCategory.swift +++ b/DevLog/Domain/Entity/UserTodoCategory.swift @@ -8,6 +8,7 @@ import Foundation struct UserTodoCategory: Equatable { + var id: String var name: String var colorHex: String } diff --git a/DevLog/Infra/Service/TodoCategoryService.swift b/DevLog/Infra/Service/TodoCategoryService.swift index 7fa18511..ba8930cc 100644 --- a/DevLog/Infra/Service/TodoCategoryService.swift +++ b/DevLog/Infra/Service/TodoCategoryService.swift @@ -12,6 +12,7 @@ final class TodoCategoryService { private enum Field: String { case items case kind + case id case systemCategory case name case colorHex @@ -138,6 +139,7 @@ private extension TodoCategoryService { ) case .user: guard + let id = items[Field.id.rawValue] as? String, let name = items[Field.name.rawValue] as? String, let colorHex = items[Field.colorHex.rawValue] as? String else { @@ -147,6 +149,7 @@ private extension TodoCategoryService { return TodoCategoryPreference( category: .user( UserTodoCategory( + id: id, name: name, colorHex: colorHex ) @@ -167,6 +170,7 @@ private extension TodoCategoryService { case .user(let userTodoCategory): return [ Field.kind.rawValue: Kind.user.rawValue, + Field.id.rawValue: userTodoCategory.id, Field.name.rawValue: userTodoCategory.name, Field.colorHex.rawValue: userTodoCategory.colorHex, Field.isVisible.rawValue: preference.isVisible diff --git a/DevLog/Presentation/Extension/UserTodoCategory+Presentation.swift b/DevLog/Presentation/Extension/UserTodoCategory+Presentation.swift index a105b6c4..334357d4 100644 --- a/DevLog/Presentation/Extension/UserTodoCategory+Presentation.swift +++ b/DevLog/Presentation/Extension/UserTodoCategory+Presentation.swift @@ -7,13 +7,11 @@ import SwiftUI -extension UserTodoCategory: Identifiable { - var id: String { name } -} +extension UserTodoCategory: Identifiable { } extension UserTodoCategory: Hashable { func hash(into hasher: inout Hasher) { - hasher.combine(name) + hasher.combine(id) hasher.combine(colorHex) } } diff --git a/DevLog/Presentation/ViewModel/TodoManageViewModel.swift b/DevLog/Presentation/ViewModel/TodoManageViewModel.swift index 6c961ae8..78dbbf4c 100644 --- a/DevLog/Presentation/ViewModel/TodoManageViewModel.swift +++ b/DevLog/Presentation/ViewModel/TodoManageViewModel.swift @@ -95,6 +95,7 @@ final class TodoManageViewModel: Store { TodoCategoryPreference( category: .user( UserTodoCategory( + id: UUID().uuidString.lowercased(), name: trimmedCategoryName, colorHex: colorHex ) diff --git a/Firebase/functions/src/todo/update.ts b/Firebase/functions/src/todo/update.ts index 28cc6d49..930869be 100644 --- a/Firebase/functions/src/todo/update.ts +++ b/Firebase/functions/src/todo/update.ts @@ -46,32 +46,7 @@ async function updateNotifications( todoId: string, todoCategory: string ): Promise { - let lastDocument: - FirebaseFirestore.QueryDocumentSnapshot | undefined; - - while (true) { - let query = admin.firestore() - .collection(`users/${userId}/notifications`) - .where("todoId", "==", todoId) - .orderBy(admin.firestore.FieldPath.documentId()) - .limit(BATCH_SIZE); - - if (lastDocument) { - query = query.startAfter(lastDocument); - } - - const snapshot = await query.get(); - if (snapshot.empty) { return; } - - const batch = admin.firestore().batch(); - snapshot.docs.forEach((document) => { - batch.update(document.ref, { todoCategory }); - }); - await batch.commit(); - - if (snapshot.size < BATCH_SIZE) { return; } - lastDocument = snapshot.docs[snapshot.docs.length - 1]; - } + await updateNotificationBatch(userId, todoId, todoCategory) } async function updateNotificationTasks( @@ -79,31 +54,78 @@ async function updateNotificationTasks( todoId: string, todoCategory: string ): Promise { - let lastDocument: - FirebaseFirestore.QueryDocumentSnapshot | undefined; - - while (true) { - let query = admin.firestore() - .collection("notificationTasks") - .where("userId", "==", userId) - .where("todoId", "==", todoId) - .orderBy(admin.firestore.FieldPath.documentId()) - .limit(BATCH_SIZE); - - if (lastDocument) { - query = query.startAfter(lastDocument); - } + await updateNotificationTaskBatch(userId, todoId, todoCategory) +} + +async function updateNotificationBatch( + userId: string, + todoId: string, + todoCategory: string, + lastDocument?: + FirebaseFirestore.QueryDocumentSnapshot +): Promise { + let query = admin.firestore() + .collection(`users/${userId}/notifications`) + .where("todoId", "==", todoId) + .orderBy(admin.firestore.FieldPath.documentId()) + .limit(BATCH_SIZE); + + if (lastDocument) { + query = query.startAfter(lastDocument); + } + + const snapshot = await query.get(); + if (snapshot.empty) { return; } + + const batch = admin.firestore().batch(); + snapshot.docs.forEach((document) => { + batch.update(document.ref, { todoCategory }); + }); + await batch.commit(); - const snapshot = await query.get(); - if (snapshot.empty) { return; } + if (snapshot.size < BATCH_SIZE) { return; } - const batch = admin.firestore().batch(); - snapshot.docs.forEach((document) => { - batch.update(document.ref, { todoCategory }); - }); - await batch.commit(); + await updateNotificationBatch( + userId, + todoId, + todoCategory, + snapshot.docs[snapshot.docs.length - 1] + ); +} - if (snapshot.size < BATCH_SIZE) { return; } - lastDocument = snapshot.docs[snapshot.docs.length - 1]; +async function updateNotificationTaskBatch( + userId: string, + todoId: string, + todoCategory: string, + lastDocument?: + FirebaseFirestore.QueryDocumentSnapshot +): Promise { + let query = admin.firestore() + .collection("notificationTasks") + .where("userId", "==", userId) + .where("todoId", "==", todoId) + .orderBy(admin.firestore.FieldPath.documentId()) + .limit(BATCH_SIZE); + + if (lastDocument) { + query = query.startAfter(lastDocument); } + + const snapshot = await query.get(); + if (snapshot.empty) { return; } + + const batch = admin.firestore().batch(); + snapshot.docs.forEach((document) => { + batch.update(document.ref, { todoCategory }); + }); + await batch.commit(); + + if (snapshot.size < BATCH_SIZE) { return; } + + await updateNotificationTaskBatch( + userId, + todoId, + todoCategory, + snapshot.docs[snapshot.docs.length - 1] + ); } diff --git a/Firebase/functions/src/todoCategory/update.ts b/Firebase/functions/src/todoCategory/update.ts index 491c9ab0..1aee0eb6 100644 --- a/Firebase/functions/src/todoCategory/update.ts +++ b/Firebase/functions/src/todoCategory/update.ts @@ -11,12 +11,12 @@ const ETC_CATEGORY = "etc"; type CategoryItem = { kind?: unknown; - name?: unknown; + id?: unknown; }; type TodoCategoryUpdateTaskData = { userId: string; - categoryName: string; + id: string; createdAt?: FirebaseFirestore.Timestamp | Date | null; }; @@ -33,20 +33,20 @@ export const requestMoveRemovedCategoryTodosToEtc = onDocumentUpdated({ const beforeItems = Array.isArray(beforeData.items) ? beforeData.items as CategoryItem[] : []; const afterItems = Array.isArray(afterData.items) ? afterData.items as CategoryItem[] : []; - const removedNames = getRemovedNames(beforeItems, afterItems); + const removedIDs = getRemovedIDs(beforeItems, afterItems); - if (removedNames.length === 0) { return; } + if (removedIDs.length === 0) { return; } try { const queue = getFunctions().taskQueue( `locations/${LOCATION}/functions/completeMoveRemovedCategoryTodosToEtc` ); - for (const categoryName of removedNames) { + for (const id of removedIDs) { const taskRef = admin.firestore().collection("todoCategoryUpdateTasks").doc(); const taskData = { userId, - categoryName, + id, createdAt: admin.firestore.FieldValue.serverTimestamp() }; @@ -59,7 +59,7 @@ export const requestMoveRemovedCategoryTodosToEtc = onDocumentUpdated({ } catch (cleanupError) { logger.warn("todoCategoryUpdateTasks 정리 실패", { userId, - categoryName, + id, taskId: taskRef.id, error: normalizeError(cleanupError) }); @@ -71,7 +71,7 @@ export const requestMoveRemovedCategoryTodosToEtc = onDocumentUpdated({ } catch (error) { logger.error("삭제된 사용자 카테고리 todo 정리 요청 실패", { userId, - removedNames, + removedIDs, error: normalizeError(error) }); throw error; @@ -97,20 +97,20 @@ export const completeMoveRemovedCategoryTodosToEtc = onTaskDispatched({ const taskData = taskSnapshot.data() as TodoCategoryUpdateTaskData | undefined; const userId = typeof taskData?.userId === "string" ? taskData.userId : ""; - const categoryName = typeof taskData?.categoryName === "string" ? taskData.categoryName : ""; + const id = typeof taskData?.id === "string" ? taskData.id : ""; - if (!userId || !categoryName) { + if (!userId || !id) { logger.warn("todoCategoryUpdateTasks 문서 형식이 올바르지 않습니다.", { taskId }); return; } try { - await updateTodos(userId, categoryName); + await updateTodos(userId, id); await taskRef.delete(); } catch (error) { logger.error("삭제된 사용자 카테고리 todo 정리 실패", { userId, - categoryName, + id, taskId, error: normalizeError(error) }); @@ -119,56 +119,65 @@ export const completeMoveRemovedCategoryTodosToEtc = onTaskDispatched({ } ); -function getRemovedNames( +function getRemovedIDs( beforeItems: CategoryItem[], afterItems: CategoryItem[] ): string[] { - const beforeNames = new Set( + const beforeIDs = new Set( beforeItems.flatMap((item) => { if (item.kind !== "user") { return []; } - return typeof item.name === "string" ? [item.name] : []; + return typeof item.id === "string" ? [item.id] : []; }) ); - const afterNames = new Set( + const afterIDs = new Set( afterItems.flatMap((item) => { if (item.kind !== "user") { return []; } - return typeof item.name === "string" ? [item.name] : []; + return typeof item.id === "string" ? [item.id] : []; }) ); - return Array.from(beforeNames).filter((name) => !afterNames.has(name)); + return Array.from(beforeIDs).filter((id) => !afterIDs.has(id)); } async function updateTodos( userId: string, - categoryName: string + id: string ): Promise { - let lastDocument: - FirebaseFirestore.QueryDocumentSnapshot | undefined; - - while (true) { - let query = admin.firestore() - .collection(`users/${userId}/todoLists`) - .where("category", "==", categoryName) - .orderBy(admin.firestore.FieldPath.documentId()) - .limit(BATCH_SIZE); - - if (lastDocument) { - query = query.startAfter(lastDocument); - } + await updateTodoBatch(userId, id) +} + +async function updateTodoBatch( + userId: string, + id: string, + lastDocument?: + FirebaseFirestore.QueryDocumentSnapshot +): Promise { + let query = admin.firestore() + .collection(`users/${userId}/todoLists`) + .where("category", "==", id) + .orderBy(admin.firestore.FieldPath.documentId()) + .limit(BATCH_SIZE); + + if (lastDocument) { + query = query.startAfter(lastDocument); + } - const querySnapshot = await query.get(); - if (querySnapshot.empty) { return; } + const snapshot = await query.get(); + if (snapshot.empty) { return; } - const batch = admin.firestore().batch(); - querySnapshot.docs.forEach((document) => { - batch.update(document.ref, { - category: ETC_CATEGORY - }); + const batch = admin.firestore().batch(); + snapshot.docs.forEach((document) => { + batch.update(document.ref, { + category: ETC_CATEGORY }); - await batch.commit(); + }); + await batch.commit(); - if (querySnapshot.size < BATCH_SIZE) { return; } - lastDocument = querySnapshot.docs[querySnapshot.docs.length - 1]; - } + if (snapshot.size < BATCH_SIZE) { return; } + + await updateTodoBatch( + userId, + id, + snapshot.docs[snapshot.docs.length - 1] + ); } From b29a8b5a3479ce975da58d5731f33b3eb7fae1a5 Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 30 Mar 2026 17:20:11 +0900 Subject: [PATCH 17/29] =?UTF-8?q?chore:=20firebase=20=EC=97=85=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Firebase/package-lock.json | 3129 ++++++++++++++++++++++++++++++++++++ Firebase/package.json | 5 + 2 files changed, 3134 insertions(+) create mode 100644 Firebase/package-lock.json create mode 100644 Firebase/package.json diff --git a/Firebase/package-lock.json b/Firebase/package-lock.json new file mode 100644 index 00000000..931089a8 --- /dev/null +++ b/Firebase/package-lock.json @@ -0,0 +1,3129 @@ +{ + "name": "Firebase", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "firebase-functions": "^7.2.2" + } + }, + "node_modules/@fastify/busboy": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-3.2.0.tgz", + "integrity": "sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==", + "license": "MIT", + "peer": true + }, + "node_modules/@firebase/app-check-interop-types": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@firebase/app-check-interop-types/-/app-check-interop-types-0.3.3.tgz", + "integrity": "sha512-gAlxfPLT2j8bTI/qfe3ahl2I2YcBQ8cFIBdhAQA4I2f3TndcO+22YizyGYuttLHPQEpWkhmpFW60VCFEPg4g5A==", + "license": "Apache-2.0", + "peer": true + }, + "node_modules/@firebase/app-types": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/@firebase/app-types/-/app-types-0.9.3.tgz", + "integrity": "sha512-kRVpIl4vVGJ4baogMDINbyrIOtOxqhkZQg4jTq3l8Lw6WSk0xfpEYzezFu+Kl4ve4fbPl79dvwRtaFqAC/ucCw==", + "license": "Apache-2.0", + "peer": true + }, + "node_modules/@firebase/auth-interop-types": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@firebase/auth-interop-types/-/auth-interop-types-0.2.4.tgz", + "integrity": "sha512-JPgcXKCuO+CWqGDnigBtvo09HeBs5u/Ktc2GaFj2m01hLarbxthLNm7Fk8iOP1aqAtXV+fnnGj7U28xmk7IwVA==", + "license": "Apache-2.0", + "peer": true + }, + "node_modules/@firebase/component": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@firebase/component/-/component-0.7.2.tgz", + "integrity": "sha512-iyVDGc6Vjx7Rm0cAdccLH/NG6fADsgJak/XW9IA2lPf8AjIlsemOpFGKczYyPHxm4rnKdR8z6sK4+KEC7NwmEg==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@firebase/util": "1.15.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/database": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@firebase/database/-/database-1.1.2.tgz", + "integrity": "sha512-lP96CMjMPy/+d1d9qaaHjHHdzdwvEOuyyLq9ehX89e2XMKwS1jHNzYBO+42bdSumuj5ukPbmnFtViZu8YOMT+w==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@firebase/app-check-interop-types": "0.3.3", + "@firebase/auth-interop-types": "0.2.4", + "@firebase/component": "0.7.2", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.15.0", + "faye-websocket": "0.11.4", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/database-compat": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@firebase/database-compat/-/database-compat-2.1.2.tgz", + "integrity": "sha512-j4A6IhVZbgxAzT6gJJC2PfOxYCK9SrDrUO7nTM4EscTYtKkAkzsbKoCnDdjFapQfnsncvPWjqVTr/0PffUwg3g==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@firebase/component": "0.7.2", + "@firebase/database": "1.1.2", + "@firebase/database-types": "1.0.18", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.15.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/database-types": { + "version": "1.0.18", + "resolved": "https://registry.npmjs.org/@firebase/database-types/-/database-types-1.0.18.tgz", + "integrity": "sha512-yOY8IC2go9lfbVDMiy2ATun4EB2AFwocPaQADwMN/RHRUAZSM4rlAV7PGbWPSG/YhkJ2A9xQAiAENgSua9G5Fg==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@firebase/app-types": "0.9.3", + "@firebase/util": "1.15.0" + } + }, + "node_modules/@firebase/logger": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@firebase/logger/-/logger-0.5.0.tgz", + "integrity": "sha512-cGskaAvkrnh42b3BA3doDWeBmuHFO/Mx5A83rbRDYakPjO9bJtRL3dX7javzc2Rr/JHZf4HlterTW2lUkfeN4g==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/util": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@firebase/util/-/util-1.15.0.tgz", + "integrity": "sha512-AmWf3cHAOMbrCPG4xdPKQaj5iHnyYfyLKZxwz+Xf55bqKbpAmcYifB4jQinT2W9XhDRHISOoPyBOariJpCG6FA==", + "hasInstallScript": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@google-cloud/firestore": { + "version": "7.11.6", + "resolved": "https://registry.npmjs.org/@google-cloud/firestore/-/firestore-7.11.6.tgz", + "integrity": "sha512-EW/O8ktzwLfyWBOsNuhRoMi8lrC3clHM5LVFhGvO1HCsLozCOOXRAlHrYBoE6HL42Sc8yYMuCb2XqcnJ4OOEpw==", + "license": "Apache-2.0", + "optional": true, + "peer": true, + "dependencies": { + "@opentelemetry/api": "^1.3.0", + "fast-deep-equal": "^3.1.1", + "functional-red-black-tree": "^1.0.1", + "google-gax": "^4.3.3", + "protobufjs": "^7.2.6" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@google-cloud/paginator": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@google-cloud/paginator/-/paginator-5.0.2.tgz", + "integrity": "sha512-DJS3s0OVH4zFDB1PzjxAsHqJT6sKVbRwwML0ZBP9PbU7Yebtu/7SWMRzvO2J3nUi9pRNITCfu4LJeooM2w4pjg==", + "license": "Apache-2.0", + "optional": true, + "peer": true, + "dependencies": { + "arrify": "^2.0.0", + "extend": "^3.0.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@google-cloud/projectify": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@google-cloud/projectify/-/projectify-4.0.0.tgz", + "integrity": "sha512-MmaX6HeSvyPbWGwFq7mXdo0uQZLGBYCwziiLIGq5JVX+/bdI3SAq6bP98trV5eTWfLuvsMcIC1YJOF2vfteLFA==", + "license": "Apache-2.0", + "optional": true, + "peer": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@google-cloud/promisify": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@google-cloud/promisify/-/promisify-4.0.0.tgz", + "integrity": "sha512-Orxzlfb9c67A15cq2JQEyVc7wEsmFBmHjZWZYQMUyJ1qivXyMwdyNOs9odi79hze+2zqdTtu1E19IM/FtqZ10g==", + "license": "Apache-2.0", + "optional": true, + "peer": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@google-cloud/storage": { + "version": "7.19.0", + "resolved": "https://registry.npmjs.org/@google-cloud/storage/-/storage-7.19.0.tgz", + "integrity": "sha512-n2FjE7NAOYyshogdc7KQOl/VZb4sneqPjWouSyia9CMDdMhRX5+RIbqalNmC7LOLzuLAN89VlF2HvG8na9G+zQ==", + "license": "Apache-2.0", + "optional": true, + "peer": true, + "dependencies": { + "@google-cloud/paginator": "^5.0.0", + "@google-cloud/projectify": "^4.0.0", + "@google-cloud/promisify": "<4.1.0", + "abort-controller": "^3.0.0", + "async-retry": "^1.3.3", + "duplexify": "^4.1.3", + "fast-xml-parser": "^5.3.4", + "gaxios": "^6.0.2", + "google-auth-library": "^9.6.3", + "html-entities": "^2.5.2", + "mime": "^3.0.0", + "p-limit": "^3.0.1", + "retry-request": "^7.0.0", + "teeny-request": "^9.0.0", + "uuid": "^8.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@google-cloud/storage/node_modules/gcp-metadata": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.1.tgz", + "integrity": "sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==", + "license": "Apache-2.0", + "optional": true, + "peer": true, + "dependencies": { + "gaxios": "^6.1.1", + "google-logging-utils": "^0.0.2", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@google-cloud/storage/node_modules/google-auth-library": { + "version": "9.15.1", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.15.1.tgz", + "integrity": "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==", + "license": "Apache-2.0", + "optional": true, + "peer": true, + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^6.1.1", + "gcp-metadata": "^6.1.0", + "gtoken": "^7.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@google-cloud/storage/node_modules/google-logging-utils": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-0.0.2.tgz", + "integrity": "sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==", + "license": "Apache-2.0", + "optional": true, + "peer": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@google-cloud/storage/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "license": "MIT", + "optional": true, + "peer": true, + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/@grpc/grpc-js": { + "version": "1.14.3", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.3.tgz", + "integrity": "sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==", + "license": "Apache-2.0", + "optional": true, + "peer": true, + "dependencies": { + "@grpc/proto-loader": "^0.8.0", + "@js-sdsl/ordered-map": "^4.4.2" + }, + "engines": { + "node": ">=12.10.0" + } + }, + "node_modules/@grpc/grpc-js/node_modules/@grpc/proto-loader": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.8.0.tgz", + "integrity": "sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ==", + "license": "Apache-2.0", + "optional": true, + "peer": true, + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.5.3", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@grpc/proto-loader": { + "version": "0.7.15", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.15.tgz", + "integrity": "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==", + "license": "Apache-2.0", + "optional": true, + "peer": true, + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.2.5", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@js-sdsl/ordered-map": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz", + "integrity": "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==", + "license": "MIT", + "optional": true, + "peer": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/js-sdsl" + } + }, + "node_modules/@opentelemetry/api": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", + "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", + "license": "Apache-2.0", + "optional": true, + "peer": true, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", + "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", + "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1", + "@protobufjs/inquire": "^1.1.0" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", + "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", + "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", + "license": "BSD-3-Clause" + }, + "node_modules/@tootallnate/once": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", + "integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/caseless": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/@types/caseless/-/caseless-0.12.5.tgz", + "integrity": "sha512-hWtVTC2q7hc7xZ/RLbxapMvDMgUnDvKvMOpKal4DrMyfGBUfB1oKaZlIRr6mJL+If3bAP6sV/QneGzF6tJjZDg==", + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/express": { + "version": "4.17.25", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", + "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "^1" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "4.19.8", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.8.tgz", + "integrity": "sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "license": "MIT" + }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, + "node_modules/@types/long": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz", + "integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==", + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "license": "MIT" + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT", + "peer": true + }, + "node_modules/@types/node": { + "version": "25.5.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", + "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/qs": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==", + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "license": "MIT" + }, + "node_modules/@types/request": { + "version": "2.48.13", + "resolved": "https://registry.npmjs.org/@types/request/-/request-2.48.13.tgz", + "integrity": "sha512-FGJ6udDNUCjd19pp0Q3iTiDkwhYup7J8hpMW9c4k53NrccQFFWKRho6hvtPPEhnXWKvukfwAlB6DbDz4yhH5Gg==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@types/caseless": "*", + "@types/node": "*", + "@types/tough-cookie": "*", + "form-data": "^2.5.5" + } + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.10", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", + "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "<1" + } + }, + "node_modules/@types/serve-static/node_modules/@types/send": { + "version": "0.17.6", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", + "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/@types/tough-cookie": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", + "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 14" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/arrify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/arrify/-/arrify-2.0.1.tgz", + "integrity": "sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/async-retry": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/async-retry/-/async-retry-1.3.3.tgz", + "integrity": "sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "retry": "0.13.1" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "peer": true + }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": "*" + } + }, + "node_modules/body-parser": { + "version": "1.20.4", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", + "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause", + "peer": true + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "optional": true, + "peer": true, + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 12" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/duplexify": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.3.tgz", + "integrity": "sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "end-of-stream": "^1.4.1", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1", + "stream-shift": "^1.0.2" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT", + "peer": true + }, + "node_modules/farmhash-modern": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/farmhash-modern/-/farmhash-modern-1.1.0.tgz", + "integrity": "sha512-6ypT4XfgqJk/F3Yuv4SX26I3doUjt0GTG4a+JgWxXQpxXzTBq8fPUeGHfcYMMDPHJHm3yPOSjaeBwBGAHWXCdA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT", + "peer": true + }, + "node_modules/fast-xml-builder": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz", + "integrity": "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "path-expression-matcher": "^1.1.3" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.5.9", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.9.tgz", + "integrity": "sha512-jldvxr1MC6rtiZKgrFnDSvT8xuH+eJqxqOBThUVjYrxssYTo1avZLGql5l0a0BAERR01CadYzZ83kVEkbyDg+g==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "fast-xml-builder": "^1.1.4", + "path-expression-matcher": "^1.2.0", + "strnum": "^2.2.2" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/faye-websocket": { + "version": "0.11.4", + "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz", + "integrity": "sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "websocket-driver": ">=0.5.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/firebase-admin": { + "version": "13.7.0", + "resolved": "https://registry.npmjs.org/firebase-admin/-/firebase-admin-13.7.0.tgz", + "integrity": "sha512-o3qS8zCJbApe7aKzkO2Pa380t9cHISqeSd3blqYTtOuUUUua3qZTLwNWgGUOss3td6wbzrZhiHIj3c8+fC046Q==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@fastify/busboy": "^3.0.0", + "@firebase/database-compat": "^2.0.0", + "@firebase/database-types": "^1.0.6", + "farmhash-modern": "^1.1.0", + "fast-deep-equal": "^3.1.1", + "google-auth-library": "^10.6.1", + "jsonwebtoken": "^9.0.0", + "jwks-rsa": "^3.1.0", + "node-forge": "^1.3.1", + "uuid": "^11.0.2" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@google-cloud/firestore": "^7.11.0", + "@google-cloud/storage": "^7.19.0" + } + }, + "node_modules/firebase-functions": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/firebase-functions/-/firebase-functions-7.2.2.tgz", + "integrity": "sha512-fWFVI+4weuaat+Fp+4xYY1T+omiTvya8fW79+edgLWCOaDEBSBNlfhstnt+K1esblscZlJf8v+IA0LsCG8Uf1Q==", + "license": "MIT", + "dependencies": { + "@types/cors": "^2.8.5", + "@types/express": "^4.17.21", + "cors": "^2.8.5", + "express": "^4.21.0", + "protobufjs": "^7.2.2" + }, + "bin": { + "firebase-functions": "lib/bin/firebase-functions.js" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@apollo/server": "^5.2.0", + "@as-integrations/express4": "^1.1.2", + "firebase-admin": "^11.10.0 || ^12.0.0 || ^13.0.0", + "graphql": "^16.12.0" + }, + "peerDependenciesMeta": { + "@apollo/server": { + "optional": true + }, + "@as-integrations/express4": { + "optional": true + }, + "graphql": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "2.5.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.5.tgz", + "integrity": "sha512-jqdObeR2rxZZbPSGL+3VckHMYtu+f9//KXBsVny6JSX/pa38Fy+bGjuG8eW/H6USNQWhLi8Num++cU2yOCNz4A==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.35", + "safe-buffer": "^5.2.1" + }, + "engines": { + "node": ">= 0.12" + } + }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "peer": true, + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functional-red-black-tree": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz", + "integrity": "sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g==", + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/gaxios": { + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.7.1.tgz", + "integrity": "sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==", + "license": "Apache-2.0", + "optional": true, + "peer": true, + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "is-stream": "^2.0.0", + "node-fetch": "^2.6.9", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/gaxios/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "optional": true, + "peer": true, + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/gcp-metadata": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gcp-metadata/node_modules/gaxios": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.4.tgz", + "integrity": "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gcp-metadata/node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "peer": true, + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "optional": true, + "peer": true, + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/google-auth-library": { + "version": "10.6.2", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.6.2.tgz", + "integrity": "sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.1.4", + "gcp-metadata": "8.1.2", + "google-logging-utils": "1.1.3", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-auth-library/node_modules/gaxios": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.4.tgz", + "integrity": "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-auth-library/node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "peer": true, + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/google-gax": { + "version": "4.6.1", + "resolved": "https://registry.npmjs.org/google-gax/-/google-gax-4.6.1.tgz", + "integrity": "sha512-V6eky/xz2mcKfAd1Ioxyd6nmA61gao3n01C+YeuIwu3vzM9EDR6wcVzMSIbLMDXWeoi9SHYctXuKYC5uJUT3eQ==", + "license": "Apache-2.0", + "optional": true, + "peer": true, + "dependencies": { + "@grpc/grpc-js": "^1.10.9", + "@grpc/proto-loader": "^0.7.13", + "@types/long": "^4.0.0", + "abort-controller": "^3.0.0", + "duplexify": "^4.0.0", + "google-auth-library": "^9.3.0", + "node-fetch": "^2.7.0", + "object-hash": "^3.0.0", + "proto3-json-serializer": "^2.0.2", + "protobufjs": "^7.3.2", + "retry-request": "^7.0.0", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/google-gax/node_modules/gcp-metadata": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.1.tgz", + "integrity": "sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==", + "license": "Apache-2.0", + "optional": true, + "peer": true, + "dependencies": { + "gaxios": "^6.1.1", + "google-logging-utils": "^0.0.2", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/google-gax/node_modules/google-auth-library": { + "version": "9.15.1", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.15.1.tgz", + "integrity": "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==", + "license": "Apache-2.0", + "optional": true, + "peer": true, + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^6.1.1", + "gcp-metadata": "^6.1.0", + "gtoken": "^7.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/google-gax/node_modules/google-logging-utils": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-0.0.2.tgz", + "integrity": "sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==", + "license": "Apache-2.0", + "optional": true, + "peer": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/google-gax/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "optional": true, + "peer": true, + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "license": "Apache-2.0", + "peer": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gtoken": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-7.1.0.tgz", + "integrity": "sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "gaxios": "^6.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/html-entities": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz", + "integrity": "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/mdevils" + }, + { + "type": "patreon", + "url": "https://patreon.com/mdevils" + } + ], + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/http-parser-js": { + "version": "0.5.10", + "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.10.tgz", + "integrity": "sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==", + "license": "MIT", + "peer": true + }, + "node_modules/http-proxy-agent": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", + "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@tootallnate/once": "2", + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/http-proxy-agent/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/http-proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/http-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "peer": true, + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "peer": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/https-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT", + "peer": true + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jose": { + "version": "4.15.9", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz", + "integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==", + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "peer": true, + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsonwebtoken/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT", + "peer": true + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "peer": true, + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jwks-rsa": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jwks-rsa/-/jwks-rsa-3.2.2.tgz", + "integrity": "sha512-BqTyEDV+lS8F2trk3A+qJnxV5Q9EqKCBJOPti3W97r7qTympCZjb7h2X6f2kc+0K3rsSTY1/6YG2eaXKoj497w==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/jsonwebtoken": "^9.0.4", + "debug": "^4.3.4", + "jose": "^4.15.4", + "limiter": "^1.1.5", + "lru-memoizer": "^2.2.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/jwks-rsa/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "peer": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/jwks-rsa/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT", + "peer": true + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "peer": true, + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/limiter": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/limiter/-/limiter-1.1.5.tgz", + "integrity": "sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA==", + "peer": true + }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/lodash.clonedeep": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", + "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==", + "license": "MIT", + "peer": true + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT", + "peer": true + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT", + "peer": true + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT", + "peer": true + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT", + "peer": true + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT", + "peer": true + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT", + "peer": true + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT", + "peer": true + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "license": "ISC", + "peer": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/lru-memoizer": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/lru-memoizer/-/lru-memoizer-2.3.0.tgz", + "integrity": "sha512-GXn7gyHAMhO13WSKrIiNfztwxodVsP8IoZ3XfrJV4yH2x0/OeTO/FIaAHTY5YekdGgW94njfuKmyyt1E0mR6Ug==", + "license": "MIT", + "peer": true, + "dependencies": { + "lodash.clonedeep": "^4.5.0", + "lru-cache": "6.0.0" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "license": "MIT", + "optional": true, + "peer": true, + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-forge": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz", + "integrity": "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==", + "license": "(BSD-3-Clause OR GPL-2.0)", + "peer": true, + "engines": { + "node": ">= 6.13.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "optional": true, + "peer": true, + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-expression-matcher": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.2.0.tgz", + "integrity": "sha512-DwmPWeFn+tq7TiyJ2CxezCAirXjFxvaiD03npak3cRjlP9+OjTmSy1EpIrEbh+l6JgUundniloMLDQ/6VTdhLQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/proto3-json-serializer": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/proto3-json-serializer/-/proto3-json-serializer-2.0.2.tgz", + "integrity": "sha512-SAzp/O4Yh02jGdRc+uIrGoe87dkN/XtwxfZ4ZyafJHymd79ozp5VG5nyZ7ygqPM5+cpLDjjGnYFUkngonyDPOQ==", + "license": "Apache-2.0", + "optional": true, + "peer": true, + "dependencies": { + "protobufjs": "^7.2.5" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/protobufjs": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz", + "integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/node": ">=13.7.0", + "long": "^5.0.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/retry-request": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/retry-request/-/retry-request-7.0.2.tgz", + "integrity": "sha512-dUOvLMJ0/JJYEn8NrpOaGNE7X3vpI5XlZS/u0ANjqtcZVKnIxP7IgCFwrKTxENw29emmwug53awKtaMm4i9g5w==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@types/request": "^2.48.8", + "extend": "^3.0.2", + "teeny-request": "^9.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "peer": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/stream-events": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/stream-events/-/stream-events-1.0.5.tgz", + "integrity": "sha512-E1GUzBSgvct8Jsb3v2X15pjzN1tYebtbLaMg+eBOUOAxgbLoSbT2NS91ckc5lJD1KfLjId+jXJRgo0qnV5Nerg==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "stubs": "^3.0.0" + } + }, + "node_modules/stream-shift": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.3.tgz", + "integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==", + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strnum": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.2.tgz", + "integrity": "sha512-DnR90I+jtXNSTXWdwrEy9FakW7UX+qUZg28gj5fk2vxxl7uS/3bpI4fjFYVmdK9etptYBPNkpahuQnEwhwECqA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/stubs": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/stubs/-/stubs-3.0.0.tgz", + "integrity": "sha512-PdHt7hHUJKxvTCgbKX9C1V/ftOcjJQgz8BZwNfV5c4B6dcGqlpelTbJ999jBGZ2jYiPAwcX5dP6oBwVlBlUbxw==", + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/teeny-request": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/teeny-request/-/teeny-request-9.0.0.tgz", + "integrity": "sha512-resvxdc6Mgb7YEThw6G6bExlXKkv6+YbuzGg9xuXxSgxJF7Ozs+o8Y9+2R3sArdWdW8nOokoQb1yrpFB0pQK2g==", + "license": "Apache-2.0", + "optional": true, + "peer": true, + "dependencies": { + "http-proxy-agent": "^5.0.0", + "https-proxy-agent": "^5.0.0", + "node-fetch": "^2.6.9", + "stream-events": "^1.0.5", + "uuid": "^9.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/teeny-request/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/teeny-request/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/teeny-request/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/teeny-request/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/teeny-request/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "optional": true, + "peer": true, + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD", + "peer": true + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/uuid": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "peer": true, + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause", + "optional": true, + "peer": true + }, + "node_modules/websocket-driver": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", + "integrity": "sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "http-parser-js": ">=0.5.1", + "safe-buffer": ">=5.1.0", + "websocket-extensions": ">=0.1.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/websocket-extensions": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.4.tgz", + "integrity": "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==", + "license": "Apache-2.0", + "peer": true, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC", + "optional": true, + "peer": true + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "optional": true, + "peer": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC", + "peer": true + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "optional": true, + "peer": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/Firebase/package.json b/Firebase/package.json new file mode 100644 index 00000000..7bea15f5 --- /dev/null +++ b/Firebase/package.json @@ -0,0 +1,5 @@ +{ + "dependencies": { + "firebase-functions": "^7.2.2" + } +} From 698ab4e715e4832b254bfe9b05d91013e4c81dfc Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 30 Mar 2026 17:40:06 +0900 Subject: [PATCH 18/29] =?UTF-8?q?feat:=20TODO=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EB=8B=A4=EB=A5=B8=20TODO=EB=A5=BC=20=EC=B0=B8=EC=A1=B0=20?= =?UTF-8?q?=EC=8B=9C=20=EC=BB=A4=EC=8A=A4=ED=85=80=20=EC=B9=B4=ED=85=8C?= =?UTF-8?q?=EA=B3=A0=EB=A6=AC=EB=A1=9C=20=EB=90=98=EC=96=B4=20=EC=9E=88?= =?UTF-8?q?=EC=96=B4=EB=8F=84=20=EC=B0=B8=EC=A1=B0=EA=B0=80=20=EA=B0=80?= =?UTF-8?q?=EB=8A=A5=ED=95=98=EB=8F=84=EB=A1=9D=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DevLog/Data/DTO/TodoReferenceResponse.swift | 15 +++++ .../Data/Repository/TodoRepositoryImpl.swift | 62 ++++++++++++++++++- ...eferenceItem.swift => TodoReference.swift} | 4 +- DevLog/Domain/Protocol/TodoRepository.swift | 2 +- .../Fetch/FetchReferenceItemsUseCase.swift | 2 +- .../FetchReferenceItemsUseCaseImpl.swift | 4 +- DevLog/Infra/Service/TodoService.swift | 20 ++---- .../ViewModel/TodoDetailViewModel.swift | 6 +- .../ViewModel/TodoEditorViewModel.swift | 6 +- DevLog/UI/Common/TodoDetailContentView.swift | 2 +- .../UI/Common/TodoMarkdownContentView.swift | 4 +- 11 files changed, 95 insertions(+), 32 deletions(-) create mode 100644 DevLog/Data/DTO/TodoReferenceResponse.swift rename DevLog/Domain/Entity/{TodoReferenceItem.swift => TodoReference.swift} (69%) diff --git a/DevLog/Data/DTO/TodoReferenceResponse.swift b/DevLog/Data/DTO/TodoReferenceResponse.swift new file mode 100644 index 00000000..3f60dd2e --- /dev/null +++ b/DevLog/Data/DTO/TodoReferenceResponse.swift @@ -0,0 +1,15 @@ +// +// TodoReferenceResponse.swift +// DevLog +// +// Created by opfic on 3/30/26. +// + +import Foundation + +struct TodoReferenceResponse { + let id: String + let number: Int + let title: String + let category: TodoCategoryResponse +} diff --git a/DevLog/Data/Repository/TodoRepositoryImpl.swift b/DevLog/Data/Repository/TodoRepositoryImpl.swift index d26ef956..dac15174 100644 --- a/DevLog/Data/Repository/TodoRepositoryImpl.swift +++ b/DevLog/Data/Repository/TodoRepositoryImpl.swift @@ -59,8 +59,35 @@ final class TodoRepositoryImpl: TodoRepository { return try resolve(todoResponse, userTodoCategories: userTodoCategories).toDomain() } - func fetchReferenceItems(_ numbers: [Int]) async throws -> [Int: TodoReferenceItem] { - try await todoService.fetchReferenceItems(numbers) + func fetchReferences(_ numbers: [Int]) async throws -> [Int: TodoReference] { + async let responseTask = todoService.fetchReferences(numbers) + async let preferencesTask = todoCategoryService.fetchPreferences() + + let (responses, preferences) = try await (responseTask, preferencesTask) + let userTodoCategories: [UserTodoCategory] = preferences.compactMap { preference in + guard case .user(let category) = preference.category else { + return nil + } + + return category + } + + return try responses.reduce(into: [Int: TodoReference]()) { partialResult, pair in + let response = try resolve(pair.value, userTodoCategories: userTodoCategories) + let category: TodoCategory + switch response.category { + case .decoded(let decodedCategory): + category = decodedCategory + case .raw(let value): + throw DataError.invalidData("TodoReferenceResponse.category is invalid: \(value)") + } + + partialResult[pair.key] = TodoReference( + id: response.id, + title: response.title, + category: category + ) + } } func upsertTodo(_ todo: Todo) async throws { @@ -117,4 +144,35 @@ private extension TodoRepositoryImpl { category: .decoded(category) ) } + + func resolve( + _ response: TodoReferenceResponse, + userTodoCategories: [UserTodoCategory] + ) throws -> TodoReferenceResponse { + let categoryID: String + switch response.category { + case .raw(let value): + categoryID = value + case .decoded: + return response + } + + let category: TodoCategory + if let systemTodoCategory = SystemTodoCategory(rawValue: categoryID) { + category = .system(systemTodoCategory) + } else if let userTodoCategory = userTodoCategories.first(where: { + $0.id == categoryID + }) { + category = .user(userTodoCategory) + } else { + throw DataError.invalidData("TodoReferenceResponse.category is invalid: \(categoryID)") + } + + return TodoReferenceResponse( + id: response.id, + number: response.number, + title: response.title, + category: .decoded(category) + ) + } } diff --git a/DevLog/Domain/Entity/TodoReferenceItem.swift b/DevLog/Domain/Entity/TodoReference.swift similarity index 69% rename from DevLog/Domain/Entity/TodoReferenceItem.swift rename to DevLog/Domain/Entity/TodoReference.swift index 8747b412..cedd6a02 100644 --- a/DevLog/Domain/Entity/TodoReferenceItem.swift +++ b/DevLog/Domain/Entity/TodoReference.swift @@ -1,5 +1,5 @@ // -// TodoReferenceItem.swift +// TodoReference.swift // DevLog // // Created by opfic on 3/25/26. @@ -7,7 +7,7 @@ import Foundation -struct TodoReferenceItem: Equatable { +struct TodoReference: Equatable { let id: String let title: String let category: TodoCategory diff --git a/DevLog/Domain/Protocol/TodoRepository.swift b/DevLog/Domain/Protocol/TodoRepository.swift index 925236ea..0961ec9b 100644 --- a/DevLog/Domain/Protocol/TodoRepository.swift +++ b/DevLog/Domain/Protocol/TodoRepository.swift @@ -10,7 +10,7 @@ import Foundation protocol TodoRepository { func fetchTodos(_ query: TodoQuery, cursor: TodoCursor?) async throws -> TodoPage func fetchTodo(_ todoId: String) async throws -> Todo - func fetchReferenceItems(_ numbers: [Int]) async throws -> [Int: TodoReferenceItem] + func fetchReferences(_ numbers: [Int]) async throws -> [Int: TodoReference] func upsertTodo(_ todo: Todo) async throws func deleteTodo(_ todoId: String) async throws func undoDeleteTodo(_ todoId: String) async throws diff --git a/DevLog/Domain/UseCase/Todo/Fetch/FetchReferenceItemsUseCase.swift b/DevLog/Domain/UseCase/Todo/Fetch/FetchReferenceItemsUseCase.swift index f2323acc..62c43ffd 100644 --- a/DevLog/Domain/UseCase/Todo/Fetch/FetchReferenceItemsUseCase.swift +++ b/DevLog/Domain/UseCase/Todo/Fetch/FetchReferenceItemsUseCase.swift @@ -6,5 +6,5 @@ // protocol FetchReferenceItemsUseCase { - func execute(_ numbers: [Int]) async throws -> [Int: TodoReferenceItem] + func execute(_ numbers: [Int]) async throws -> [Int: TodoReference] } diff --git a/DevLog/Domain/UseCase/Todo/Fetch/FetchReferenceItemsUseCaseImpl.swift b/DevLog/Domain/UseCase/Todo/Fetch/FetchReferenceItemsUseCaseImpl.swift index c297a1d8..866c1c29 100644 --- a/DevLog/Domain/UseCase/Todo/Fetch/FetchReferenceItemsUseCaseImpl.swift +++ b/DevLog/Domain/UseCase/Todo/Fetch/FetchReferenceItemsUseCaseImpl.swift @@ -12,7 +12,7 @@ final class FetchReferenceItemsUseCaseImpl: FetchReferenceItemsUseCase { self.repository = repository } - func execute(_ numbers: [Int]) async throws -> [Int: TodoReferenceItem] { - try await repository.fetchReferenceItems(numbers) + func execute(_ numbers: [Int]) async throws -> [Int: TodoReference] { + try await repository.fetchReferences(numbers) } } diff --git a/DevLog/Infra/Service/TodoService.swift b/DevLog/Infra/Service/TodoService.swift index fc8ebd85..4ad84cb0 100644 --- a/DevLog/Infra/Service/TodoService.swift +++ b/DevLog/Infra/Service/TodoService.swift @@ -241,7 +241,7 @@ final class TodoService { } } - func fetchReferenceItems(_ numbers: [Int]) async throws -> [Int: TodoReferenceItem] { + func fetchReferences(_ numbers: [Int]) async throws -> [Int: TodoReferenceResponse] { guard let uid = Auth.auth().currentUser?.uid else { throw AuthError.notAuthenticated } let uniqueNumbers = Array(Set(numbers)).sorted() @@ -265,7 +265,7 @@ final class TodoService { return documents } - return snapshots.reduce(into: [Int: TodoReferenceItem]()) { partialResult, document in + return snapshots.reduce(into: [Int: TodoReferenceResponse]()) { partialResult, document in let data = document.data() guard !(data[TodoFieldKey.deletingAt.rawValue] is Timestamp), @@ -274,21 +274,11 @@ final class TodoService { return } - let todoCategory: TodoCategory - switch response.category { - case .raw(let category): - guard let systemTodoCategory = SystemTodoCategory(rawValue: category) else { - return - } - todoCategory = .system(systemTodoCategory) - case .decoded(let category): - todoCategory = category - } - - partialResult[response.number] = TodoReferenceItem( + partialResult[response.number] = TodoReferenceResponse( id: response.id, + number: response.number, title: response.title, - category: todoCategory + category: response.category ) } } diff --git a/DevLog/Presentation/ViewModel/TodoDetailViewModel.swift b/DevLog/Presentation/ViewModel/TodoDetailViewModel.swift index dc95c781..ceb369ce 100644 --- a/DevLog/Presentation/ViewModel/TodoDetailViewModel.swift +++ b/DevLog/Presentation/ViewModel/TodoDetailViewModel.swift @@ -12,7 +12,7 @@ final class TodoDetailViewModel: Store { struct State: Equatable { var todo: Todo? var selectedTodoId: TodoIdItem? - var referenceItems: [Int: TodoReferenceItem] = [:] + var referenceItems: [Int: TodoReference] = [:] var isLoading: Bool = false var showAlert: Bool = false var showEditor: Bool = false @@ -28,7 +28,7 @@ final class TodoDetailViewModel: Store { case setShowInfo(Bool) case setSelectedTodoId(TodoIdItem?) case setTodo(Todo) - case setReferenceItems([Int: TodoReferenceItem]) + case setReferenceItems([Int: TodoReference]) case setLoading(Bool) case upsertTodo(Todo) } @@ -108,7 +108,7 @@ final class TodoDetailViewModel: Store { case .resolveMarkdown(let content): Task { let numbers = content.todoReferenceNumbers - var referenceItems = [Int: TodoReferenceItem]() + var referenceItems = [Int: TodoReference]() if !numbers.isEmpty { do { diff --git a/DevLog/Presentation/ViewModel/TodoEditorViewModel.swift b/DevLog/Presentation/ViewModel/TodoEditorViewModel.swift index 40f0dd9e..5fa808d6 100644 --- a/DevLog/Presentation/ViewModel/TodoEditorViewModel.swift +++ b/DevLog/Presentation/ViewModel/TodoEditorViewModel.swift @@ -50,7 +50,7 @@ final class TodoEditorViewModel: Store { var selectedTodoId: TodoIdItem? var title: String = "" var content: String = "" - var referenceItems: [Int: TodoReferenceItem] = [:] + var referenceItems: [Int: TodoReference] = [:] var dueDate: Date? var showInfo: Bool = false var tags: OrderedSet = [] @@ -83,7 +83,7 @@ final class TodoEditorViewModel: Store { case setTagText(String) case setTitle(String) case setCategories([TodoCategory]) - case setReferenceItems([Int: TodoReferenceItem]) + case setReferenceItems([Int: TodoReference]) } enum SideEffect { @@ -227,7 +227,7 @@ final class TodoEditorViewModel: Store { case .resolveMarkdown(let content): Task { let numbers = content.todoReferenceNumbers - var referenceItems = [Int: TodoReferenceItem]() + var referenceItems = [Int: TodoReference]() if !numbers.isEmpty { do { diff --git a/DevLog/UI/Common/TodoDetailContentView.swift b/DevLog/UI/Common/TodoDetailContentView.swift index df685a60..802a2fa5 100644 --- a/DevLog/UI/Common/TodoDetailContentView.swift +++ b/DevLog/UI/Common/TodoDetailContentView.swift @@ -11,7 +11,7 @@ import MarkdownUI struct TodoDetailContentView: View { let title: String let content: String - let referenceItems: [Int: TodoReferenceItem] + let referenceItems: [Int: TodoReference] var number: Int? var activityLabel: String? var onOpenTodoID: ((String) -> Void)? diff --git a/DevLog/UI/Common/TodoMarkdownContentView.swift b/DevLog/UI/Common/TodoMarkdownContentView.swift index c6803644..5623138a 100644 --- a/DevLog/UI/Common/TodoMarkdownContentView.swift +++ b/DevLog/UI/Common/TodoMarkdownContentView.swift @@ -15,7 +15,7 @@ private enum TodoMarkdownSection: Equatable { struct TodoMarkdownContentView: View { let content: String - let referenceItems: [Int: TodoReferenceItem] + let referenceItems: [Int: TodoReference] var onOpenTodoID: ((String) -> Void)? var body: some View { @@ -86,7 +86,7 @@ struct TodoMarkdownContentView: View { } private struct TodoReferenceRow: View { - let item: TodoReferenceItem + let item: TodoReference let number: Int var onOpenTodoID: ((String) -> Void)? From 32652e83b5484a0b5a4296a322c9f44e8122e913 Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 30 Mar 2026 18:29:44 +0900 Subject: [PATCH 19/29] =?UTF-8?q?feat:=20=EC=82=AC=EC=9A=A9=EC=9E=90=20?= =?UTF-8?q?=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC=EB=A5=BC=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=ED=95=A0=20=EC=88=98=20=EC=9E=88=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ViewModel/TodoManageViewModel.swift | 114 +++++++++++++----- DevLog/Resource/Localizable.xcstrings | 3 - DevLog/UI/Home/TodoManageView.swift | 27 +++-- 3 files changed, 105 insertions(+), 39 deletions(-) diff --git a/DevLog/Presentation/ViewModel/TodoManageViewModel.swift b/DevLog/Presentation/ViewModel/TodoManageViewModel.swift index 78dbbf4c..d3266a7d 100644 --- a/DevLog/Presentation/ViewModel/TodoManageViewModel.swift +++ b/DevLog/Presentation/ViewModel/TodoManageViewModel.swift @@ -11,30 +11,53 @@ import SwiftUI final class TodoManageViewModel: Store { struct State: Equatable { var preferences: [TodoCategoryPreference] + var category: UserTodoCategory? var showSheet: Bool = false var showAlert: Bool = false - var deletingPreference: TodoCategoryPreference? - var categoryName: String = "" - var categoryColor: Color = .blue } enum Action { + case tapAddUserCategory case moveItem(from: IndexSet, target: Int) case tapItem(_ item: TodoCategory) + case tapEditUserCategory(TodoCategoryPreference) case tapDeleteUserCategory(TodoCategoryPreference) case confirmDeleteUserCategory case setShowSheet(Bool) case setShowAlert(Bool) case setCategoryName(String) case setCategoryColor(Color) - case addUserCategory + case saveUserCategory } enum SideEffect { } private(set) var state: State - var canAddUserCategory: Bool { - let trimmedCategoryName = state.categoryName.trimmingCharacters(in: .whitespacesAndNewlines) + + var isEditing: Bool { + guard let userTodoCategory = state.category else { + return false + } + + return state.preferences.contains { preference in + guard case .user(let currentCategory) = preference.category else { + return false + } + + return currentCategory.id == userTodoCategory.id + } + } + + var navigationTitle: String { + isEditing ? "카테고리 수정" : "카테고리 추가" + } + + var submitTitle: String { + isEditing ? "저장" : "추가" + } + + var canSubmitUserCategory: Bool { + let trimmedCategoryName = state.category?.name.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" if trimmedCategoryName.isEmpty { return false } @@ -52,61 +75,98 @@ final class TodoManageViewModel: Store { var state = self.state switch action { + case .tapAddUserCategory: + state.category = UserTodoCategory( + id: UUID().uuidString.lowercased(), + name: "", + colorHex: "#0A84FF" + ) + state.showSheet = true case .moveItem(let from, let target): state.preferences.move(fromOffsets: from, toOffset: target) case .tapItem(let item): if let index = state.preferences.firstIndex(where: { $0.category == item }) { state.preferences[index].isVisible.toggle() } + case .tapEditUserCategory(let preference): + guard case .user(let userTodoCategory) = preference.category else { + break + } + + state.category = userTodoCategory + state.showSheet = true case .tapDeleteUserCategory(let preference): - state.deletingPreference = preference + guard case .user(let userTodoCategory) = preference.category else { + break + } + + state.category = userTodoCategory state.showAlert = true case .confirmDeleteUserCategory: - guard let preference = state.deletingPreference else { + guard let userTodoCategory = state.category else { break } if let index = state.preferences.firstIndex(where: { - $0 == preference + guard case .user(let currentCategory) = $0.category else { + return false + } + + return currentCategory.id == userTodoCategory.id }) { state.preferences.remove(at: index) } state.showAlert = false - state.deletingPreference = nil + state.category = nil case .setShowSheet(let isPresented): state.showSheet = isPresented if !isPresented { - state.categoryName = "" - state.categoryColor = .blue + state.category = nil } case .setShowAlert(let isPresented): state.showAlert = isPresented if !isPresented { - state.deletingPreference = nil + state.category = nil } case .setCategoryName(let name): - state.categoryName = name + state.category?.name = name case .setCategoryColor(let color): - state.categoryColor = color - case .addUserCategory: - let trimmedCategoryName = state.categoryName.trimmingCharacters(in: .whitespacesAndNewlines) - if let colorHex = state.categoryColor.hexString { + state.category?.colorHex = color.hexString ?? "#0A84FF" + case .saveUserCategory: + guard let userTodoCategory = state.category else { + break + } + + let trimmedCategoryName = userTodoCategory.name.trimmingCharacters(in: .whitespacesAndNewlines) + let updatedCategory = UserTodoCategory( + id: userTodoCategory.id, + name: trimmedCategoryName, + colorHex: userTodoCategory.colorHex + ) + + if let index = state.preferences.firstIndex(where: { + guard case .user(let currentCategory) = $0.category else { + return false + } + + return currentCategory.id == updatedCategory.id + }) { + let preference = state.preferences[index] + state.preferences[index] = TodoCategoryPreference( + category: .user(updatedCategory), + isVisible: preference.isVisible + ) + } else { state.preferences.append( TodoCategoryPreference( - category: .user( - UserTodoCategory( - id: UUID().uuidString.lowercased(), - name: trimmedCategoryName, - colorHex: colorHex - ) - ), + category: .user(updatedCategory), isVisible: true ) ) } + state.showSheet = false - state.categoryName = "" - state.categoryColor = .blue + state.category = nil } if self.state != state { self.state = state } diff --git a/DevLog/Resource/Localizable.xcstrings b/DevLog/Resource/Localizable.xcstrings index cc88c0a7..d4e8ed6b 100644 --- a/DevLog/Resource/Localizable.xcstrings +++ b/DevLog/Resource/Localizable.xcstrings @@ -456,9 +456,6 @@ }, "카테고리 삭제" : { - }, - "카테고리 추가" : { - }, "카테고리명" : { diff --git a/DevLog/UI/Home/TodoManageView.swift b/DevLog/UI/Home/TodoManageView.swift index 10406fe9..fbefcbe8 100644 --- a/DevLog/UI/Home/TodoManageView.swift +++ b/DevLog/UI/Home/TodoManageView.swift @@ -25,6 +25,14 @@ struct TodoManageView: View { Text(category.localizedName) Spacer() if case .user = category { + Button { + viewModel.send(.tapEditUserCategory(preference)) + } label: { + Image(systemName: "slider.horizontal.3") + } + .buttonStyle(.borderless) + .padding(.trailing, 8) + Button(role: .destructive) { viewModel.send(.tapDeleteUserCategory(preference)) } label: { @@ -72,7 +80,7 @@ struct TodoManageView: View { .toolbar { ToolbarItem(placement: .navigationBarLeading) { Button { - viewModel.send(.setShowSheet(true)) + viewModel.send(.tapAddUserCategory) } label: { Image(systemName: "plus") } @@ -97,7 +105,7 @@ struct TodoManageView: View { TextField( "카테고리명", text: Binding( - get: { viewModel.state.categoryName }, + get: { viewModel.state.category?.name ?? "" }, set: { viewModel.send(.setCategoryName($0)) } ) ) @@ -106,16 +114,17 @@ struct TodoManageView: View { Section { ColorPicker(selection: Binding( - get: { viewModel.state.categoryColor }, + get: { Color(hexString: viewModel.state.category?.colorHex ?? "#0A84FF") ?? .blue }, set: { viewModel.send(.setCategoryColor($0)) } ), supportsOpacity: false) { - Text(viewModel.state.categoryColor.hexString ?? "#") - .foregroundStyle(viewModel.state.categoryColor) + let color = Color(hexString: viewModel.state.category?.colorHex ?? "#0A84FF") ?? .blue + Text(viewModel.state.category?.colorHex ?? "#") + .foregroundStyle(color) } .pickerStyle(.palette) } } - .navigationTitle("카테고리 추가") + .navigationTitle(viewModel.navigationTitle) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .navigationBarLeading) { @@ -125,10 +134,10 @@ struct TodoManageView: View { } ToolbarItem(placement: .navigationBarTrailing) { - Button("추가") { - viewModel.send(.addUserCategory) + Button(viewModel.submitTitle) { + viewModel.send(.saveUserCategory) } - .disabled(!viewModel.canAddUserCategory) + .disabled(!viewModel.canSubmitUserCategory) } } } From 02d13d809e679460013c62ae9c0b0f10987d61e9 Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 30 Mar 2026 18:36:32 +0900 Subject: [PATCH 20/29] =?UTF-8?q?feat:=20=EC=B9=B4=ED=85=8C=EA=B3=A0?= =?UTF-8?q?=EB=A6=AC=EB=A5=BC=20=EC=88=98=EC=A0=95=ED=95=B4=EB=8F=84=20?= =?UTF-8?q?=EC=B5=9C=EA=B7=BC=20=EC=88=98=EC=A0=95=20=EC=84=B9=EC=85=98?= =?UTF-8?q?=EC=97=90=EC=84=9C=EB=8A=94=20=EB=B0=98=EC=98=81=EB=90=98?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EB=8A=94=20=ED=98=84=EC=83=81=20=ED=95=B4?= =?UTF-8?q?=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Structure/RecentTodoItem.swift | 2 +- .../ViewModel/HomeViewModel.swift | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/DevLog/Presentation/Structure/RecentTodoItem.swift b/DevLog/Presentation/Structure/RecentTodoItem.swift index 77a10744..57ac2cdc 100644 --- a/DevLog/Presentation/Structure/RecentTodoItem.swift +++ b/DevLog/Presentation/Structure/RecentTodoItem.swift @@ -14,7 +14,7 @@ struct RecentTodoItem: Identifiable, Hashable { let isPinned: Bool let updatedAt: Date let tags: [String] - let category: TodoCategory + var category: TodoCategory init(from todo: Todo) { self.id = todo.id diff --git a/DevLog/Presentation/ViewModel/HomeViewModel.swift b/DevLog/Presentation/ViewModel/HomeViewModel.swift index e8fe2667..4e6c3e35 100644 --- a/DevLog/Presentation/ViewModel/HomeViewModel.swift +++ b/DevLog/Presentation/ViewModel/HomeViewModel.swift @@ -304,6 +304,7 @@ private extension HomeViewModel { return [.showModalAfterDelay(.todoEditor)] case .orderTodoCategoryPreferences(let preferences): state.preferences = preferences + state.recentTodos = syncRecentTodos(state.recentTodos, preferences: preferences) return [.updateTodoCategoryPreferences(preferences)] case .addTodo(let todo): return [.addTodo(todo)] @@ -340,6 +341,7 @@ private extension HomeViewModel { setLoading(&state, loadingTarget: loadingTarget, isLoading: isLoading) case .setTodoCategoryPreferences(let preferences): state.preferences = preferences + state.recentTodos = syncRecentTodos(state.recentTodos, preferences: preferences) case .updateRecentTodos(let todos): state.recentTodos = todos case .updateWebPages(let pages): @@ -437,6 +439,23 @@ private extension HomeViewModel { } } + func syncRecentTodos( + _ recentTodos: [RecentTodoItem], + preferences: [TodoCategoryPreference] + ) -> [RecentTodoItem] { + recentTodos.map { recentTodo in + guard let category = preferences.first(where: { + $0.category.storageValue == recentTodo.category.storageValue + })?.category else { + return recentTodo + } + + var recentTodo = recentTodo + recentTodo.category = category + return recentTodo + } + } + func normalizedWebPageURL(_ input: String) -> String? { let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return nil } From 05289dfc90dbeedfbca7efff865a130f691f9dd6 Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 30 Mar 2026 20:14:37 +0900 Subject: [PATCH 21/29] =?UTF-8?q?feat:=20=EC=B5=9C=EB=8C=80=2020=EC=9E=90?= =?UTF-8?q?=20=EC=A0=9C=ED=95=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ViewModel/TodoManageViewModel.swift | 29 +++++++++++------ DevLog/Resource/Localizable.xcstrings | 3 -- DevLog/UI/Home/TodoManageView.swift | 32 +++++++++++++------ 3 files changed, 43 insertions(+), 21 deletions(-) diff --git a/DevLog/Presentation/ViewModel/TodoManageViewModel.swift b/DevLog/Presentation/ViewModel/TodoManageViewModel.swift index d3266a7d..56c8340d 100644 --- a/DevLog/Presentation/ViewModel/TodoManageViewModel.swift +++ b/DevLog/Presentation/ViewModel/TodoManageViewModel.swift @@ -56,6 +56,14 @@ final class TodoManageViewModel: Store { isEditing ? "저장" : "추가" } + var placerholder: String { + state.category?.name ?? "이름" + } + + var categoryNameCountText: String { + "\((state.category?.name ?? "").count)/\(20)" + } + var canSubmitUserCategory: Bool { let trimmedCategoryName = state.category?.name.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" if trimmedCategoryName.isEmpty { @@ -129,19 +137,22 @@ final class TodoManageViewModel: Store { state.category = nil } case .setCategoryName(let name): - state.category?.name = name + guard var category = state.category else { break } + category.name = String(name.prefix(20)) + state.category = category case .setCategoryColor(let color): - state.category?.colorHex = color.hexString ?? "#0A84FF" + guard var category = state.category else { break } + + category.colorHex = color.hexString ?? "#0A84FF" + state.category = category case .saveUserCategory: - guard let userTodoCategory = state.category else { - break - } + guard let category = state.category else { break } - let trimmedCategoryName = userTodoCategory.name.trimmingCharacters(in: .whitespacesAndNewlines) + let name = category.name.trimmingCharacters(in: .whitespacesAndNewlines) let updatedCategory = UserTodoCategory( - id: userTodoCategory.id, - name: trimmedCategoryName, - colorHex: userTodoCategory.colorHex + id: category.id, + name: name, + colorHex: category.colorHex ) if let index = state.preferences.firstIndex(where: { diff --git a/DevLog/Resource/Localizable.xcstrings b/DevLog/Resource/Localizable.xcstrings index d4e8ed6b..0891522a 100644 --- a/DevLog/Resource/Localizable.xcstrings +++ b/DevLog/Resource/Localizable.xcstrings @@ -456,9 +456,6 @@ }, "카테고리 삭제" : { - }, - "카테고리명" : { - }, "컨텐츠" : { diff --git a/DevLog/UI/Home/TodoManageView.swift b/DevLog/UI/Home/TodoManageView.swift index fbefcbe8..05c6f002 100644 --- a/DevLog/UI/Home/TodoManageView.swift +++ b/DevLog/UI/Home/TodoManageView.swift @@ -9,6 +9,7 @@ import SwiftUI struct TodoManageView: View { @State var viewModel: TodoManageViewModel + @State private var tmpText: String = "" var onDismiss: (([TodoCategoryPreference]) -> Void)? var body: some View { @@ -23,6 +24,7 @@ struct TodoManageView: View { viewModel.send(.tapItem(category)) } Text(category.localizedName) + .lineLimit(1) Spacer() if case .user = category { Button { @@ -102,22 +104,34 @@ struct TodoManageView: View { NavigationStack { Form { Section { - TextField( - "카테고리명", - text: Binding( - get: { viewModel.state.category?.name ?? "" }, - set: { viewModel.send(.setCategoryName($0)) } + HStack(spacing: 8) { + TextField( + "", + text: $tmpText, + prompt: Text(viewModel.placerholder).foregroundStyle(.secondary) ) - ) - .frame(height: UIFont.preferredFont(forTextStyle: .body).lineHeight) + .frame(height: UIFont.preferredFont(forTextStyle: .body).lineHeight) + .onAppear { + tmpText = viewModel.state.category?.name ?? "" + } + .onChange(of: tmpText) { _, value in + viewModel.send(.setCategoryName(value)) + tmpText = viewModel.state.category?.name ?? "" + } + + Text(viewModel.categoryNameCountText) + .font(.footnote) + .foregroundStyle(.secondary) + .monospacedDigit() + } } Section { + let color = Color(hexString: viewModel.state.category?.colorHex ?? "#0A84FF") ?? .blue ColorPicker(selection: Binding( - get: { Color(hexString: viewModel.state.category?.colorHex ?? "#0A84FF") ?? .blue }, + get: { color }, set: { viewModel.send(.setCategoryColor($0)) } ), supportsOpacity: false) { - let color = Color(hexString: viewModel.state.category?.colorHex ?? "#0A84FF") ?? .blue Text(viewModel.state.category?.colorHex ?? "#") .foregroundStyle(color) } From 400cb4a99790cb11db20063914756c745c281726 Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 30 Mar 2026 21:06:40 +0900 Subject: [PATCH 22/29] =?UTF-8?q?feat:=20=EC=83=89=EC=83=81=20hex=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=EB=A5=BC=20=ED=83=AD=ED=95=98=EB=A9=B4=20?= =?UTF-8?q?=EB=9E=9C=EB=8D=A4=EC=9C=BC=EB=A1=9C=20=EC=83=89=EC=83=81?= =?UTF-8?q?=EC=9D=84=20=EB=BD=91=EB=8F=84=EB=A1=9D=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DevLog/Presentation/Extension/Color+Hex.swift | 10 +++++++++- .../Presentation/ViewModel/TodoManageViewModel.swift | 10 ++++++++-- DevLog/UI/Home/TodoManageView.swift | 10 +++++++++- 3 files changed, 26 insertions(+), 4 deletions(-) diff --git a/DevLog/Presentation/Extension/Color+Hex.swift b/DevLog/Presentation/Extension/Color+Hex.swift index d0046a11..766cc2b6 100644 --- a/DevLog/Presentation/Extension/Color+Hex.swift +++ b/DevLog/Presentation/Extension/Color+Hex.swift @@ -8,6 +8,14 @@ import SwiftUI extension Color { + static var randomValue: Color { + Color( + red: Double(Int.random(in: 0...255)) / 255, + green: Double(Int.random(in: 0...255)) / 255, + blue: Double(Int.random(in: 0...255)) / 255 + ) + } + init?(hexString: String) { let trimmedHex = hexString.trimmingCharacters(in: .whitespacesAndNewlines) let sanitizedHex = trimmedHex.hasPrefix("#") ? String(trimmedHex.dropFirst()) : trimmedHex @@ -24,7 +32,7 @@ extension Color { self.init(red: red, green: green, blue: blue) } - var hexString: String? { + var hexValue: String? { let uiColor = UIColor(self) var red: CGFloat = 0 var green: CGFloat = 0 diff --git a/DevLog/Presentation/ViewModel/TodoManageViewModel.swift b/DevLog/Presentation/ViewModel/TodoManageViewModel.swift index 56c8340d..d717bc4b 100644 --- a/DevLog/Presentation/ViewModel/TodoManageViewModel.swift +++ b/DevLog/Presentation/ViewModel/TodoManageViewModel.swift @@ -27,6 +27,7 @@ final class TodoManageViewModel: Store { case setShowAlert(Bool) case setCategoryName(String) case setCategoryColor(Color) + case setRandomCategoryColor case saveUserCategory } @@ -87,7 +88,7 @@ final class TodoManageViewModel: Store { state.category = UserTodoCategory( id: UUID().uuidString.lowercased(), name: "", - colorHex: "#0A84FF" + colorHex: Color.randomValue.hexValue ?? "#000000" ) state.showSheet = true case .moveItem(let from, let target): @@ -143,7 +144,12 @@ final class TodoManageViewModel: Store { case .setCategoryColor(let color): guard var category = state.category else { break } - category.colorHex = color.hexString ?? "#0A84FF" + category.colorHex = color.hexValue ?? "#000000" + state.category = category + case .setRandomCategoryColor: + guard var category = state.category else { break } + + category.colorHex = Color.randomValue.hexValue ?? "#000000" state.category = category case .saveUserCategory: guard let category = state.category else { break } diff --git a/DevLog/UI/Home/TodoManageView.swift b/DevLog/UI/Home/TodoManageView.swift index 05c6f002..d71df66d 100644 --- a/DevLog/UI/Home/TodoManageView.swift +++ b/DevLog/UI/Home/TodoManageView.swift @@ -127,13 +127,21 @@ struct TodoManageView: View { } Section { - let color = Color(hexString: viewModel.state.category?.colorHex ?? "#0A84FF") ?? .blue + let color = Color(hexString: viewModel.state.category?.colorHex ?? "") ?? .randomValue ColorPicker(selection: Binding( get: { color }, set: { viewModel.send(.setCategoryColor($0)) } ), supportsOpacity: false) { Text(viewModel.state.category?.colorHex ?? "#") + .overlay(alignment: .bottom) { + Rectangle() + .frame(height: 1) + .offset(y: 1) + } .foregroundStyle(color) + .onTapGesture { + viewModel.send(.setRandomCategoryColor) + } } .pickerStyle(.palette) } From b240e46f1a2b06701c6f854be2c9e35568ee2557 Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 30 Mar 2026 21:43:55 +0900 Subject: [PATCH 23/29] =?UTF-8?q?fix:=20oDomain=20=EB=A9=94=EC=84=9C?= =?UTF-8?q?=EB=93=9C=EA=B0=80=20=EB=8B=A4=EB=A5=B8=20=EA=B3=B3=EC=97=90?= =?UTF-8?q?=EC=84=9C=20resolve=EB=90=98=EC=A7=80=20=EC=95=8A=EC=9D=80=20DT?= =?UTF-8?q?O=EC=99=80=20=ED=95=A8=EA=BB=98=20=ED=98=B8=EC=B6=9C=EB=90=A0?= =?UTF-8?q?=20=EA=B2=BD=EC=9A=B0=20=EC=9E=A0=EC=9E=AC=EC=A0=81=20=EC=98=A4?= =?UTF-8?q?=EB=A5=98=20=EB=B0=9C=EC=83=9D=20=EA=B0=80=EB=8A=A5=EC=84=B1=20?= =?UTF-8?q?=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DevLog/Data/Mapper/PushNotificationMapping.swift | 8 +++----- DevLog/Data/Mapper/TodoMapping.swift | 5 +---- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/DevLog/Data/Mapper/PushNotificationMapping.swift b/DevLog/Data/Mapper/PushNotificationMapping.swift index dfb58a0a..c717d170 100644 --- a/DevLog/Data/Mapper/PushNotificationMapping.swift +++ b/DevLog/Data/Mapper/PushNotificationMapping.swift @@ -13,11 +13,9 @@ extension PushNotificationResponse { case .decoded(let category): todoCategory = category case .raw(let category): - guard let systemTodoCategory = SystemTodoCategory(rawValue: category) else { - throw DataError.invalidData("PushNotificationResponse.todoCategory is invalid: \(category)") - } - - todoCategory = .system(systemTodoCategory) + throw DataError.invalidData( + "PushNotificationResponse.todoCategory must be resolved before toDomain(): \(category)" + ) } return PushNotification( diff --git a/DevLog/Data/Mapper/TodoMapping.swift b/DevLog/Data/Mapper/TodoMapping.swift index 02b3e840..e879a1e7 100644 --- a/DevLog/Data/Mapper/TodoMapping.swift +++ b/DevLog/Data/Mapper/TodoMapping.swift @@ -33,10 +33,7 @@ extension TodoResponse { case .decoded(let category): todoCategory = category case .raw(let category): - guard let systemTodoCategory = SystemTodoCategory(rawValue: category) else { - throw DataError.invalidData("TodoResponse.category is invalid: \(category)") - } - todoCategory = .system(systemTodoCategory) + throw DataError.invalidData("TodoResponse.category must be resolved before toDomain(): \(category)") } return Todo( From bd8377def007f3e9e7ce86d5b4fc267a32f87564 Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 30 Mar 2026 21:49:46 +0900 Subject: [PATCH 24/29] =?UTF-8?q?refactor:=20=EA=B3=B5=ED=86=B5=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=EC=9D=84=20=ED=97=AC=ED=8D=BC=EB=A1=9C=20?= =?UTF-8?q?=EB=AC=B6=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../PushNotificationRepositoryImpl.swift | 60 ++++++++----------- 1 file changed, 26 insertions(+), 34 deletions(-) diff --git a/DevLog/Data/Repository/PushNotificationRepositoryImpl.swift b/DevLog/Data/Repository/PushNotificationRepositoryImpl.swift index 0e388c41..030cc0db 100644 --- a/DevLog/Data/Repository/PushNotificationRepositoryImpl.swift +++ b/DevLog/Data/Repository/PushNotificationRepositoryImpl.swift @@ -47,22 +47,7 @@ final class PushNotificationRepositoryImpl: PushNotificationRepository { async let preferencesTask = todoCategoryService.fetchPreferences() let (response, preferences) = try await (responseTask, preferencesTask) - let userTodoCategories: [UserTodoCategory] = preferences.compactMap { preference in - guard case .user(let userTodoCategory) = preference.category else { - return nil - } - - return userTodoCategory - } - - let responses = try response.items.map { - try resolve($0, userTodoCategories: userTodoCategories) - } - - return try PushNotificationPageResponse( - items: responses, - nextCursor: response.nextCursor - ).toDomain() + return try resolvePage(from: response, with: preferences) } func observeNotifications( @@ -88,23 +73,7 @@ final class PushNotificationRepositoryImpl: PushNotificationRepository { Task { do { let preferences = try await self.todoCategoryService.fetchPreferences() - let userTodoCategories: [UserTodoCategory] = preferences.compactMap { preference in - guard case .user(let userTodoCategory) = preference.category else { - return nil - } - - return userTodoCategory - } - - let responses = try response.items.map { - try self.resolve($0, userTodoCategories: userTodoCategories) - } - - let page = try PushNotificationPageResponse( - items: responses, - nextCursor: response.nextCursor - ).toDomain() - + let page = try self.resolvePage(from: response, with: preferences) subject.send(page) } catch { subject.send(completion: .failure(error)) @@ -139,7 +108,30 @@ final class PushNotificationRepositoryImpl: PushNotificationRepository { } private extension PushNotificationRepositoryImpl { - func resolve( + func resolvePage( + from response: PushNotificationPageResponse, + with preferences: [TodoCategoryPreference] + ) throws -> PushNotificationPage { + let userTodoCategories: [UserTodoCategory] = preferences.compactMap { preference in + guard case .user(let userTodoCategory) = preference.category else { + return nil + } + + return userTodoCategory + } + + let responses = try response.items.map { + try resolve($0, userTodoCategories: userTodoCategories) + } + + return try PushNotificationPageResponse( + items: responses, + nextCursor: response.nextCursor + ).toDomain() + } + + // resolvePage() 메서드에서만 사용됨 + private func resolve( _ response: PushNotificationResponse, userTodoCategories: [UserTodoCategory] ) throws -> PushNotificationResponse { From c2d5e0a722d30194135abb204a0bec9e14e6e4c8 Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 30 Mar 2026 21:50:54 +0900 Subject: [PATCH 25/29] =?UTF-8?q?refactor:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=20case=20=EB=AC=B8=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DevLog/Data/Repository/TodoRepositoryImpl.swift | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/DevLog/Data/Repository/TodoRepositoryImpl.swift b/DevLog/Data/Repository/TodoRepositoryImpl.swift index dac15174..f53affdb 100644 --- a/DevLog/Data/Repository/TodoRepositoryImpl.swift +++ b/DevLog/Data/Repository/TodoRepositoryImpl.swift @@ -74,12 +74,8 @@ final class TodoRepositoryImpl: TodoRepository { return try responses.reduce(into: [Int: TodoReference]()) { partialResult, pair in let response = try resolve(pair.value, userTodoCategories: userTodoCategories) - let category: TodoCategory - switch response.category { - case .decoded(let decodedCategory): - category = decodedCategory - case .raw(let value): - throw DataError.invalidData("TodoReferenceResponse.category is invalid: \(value)") + guard case let .decoded(category) = response.category else { + throw DataError.invalidData("TodoReferenceResponse.category must be resolved before use") } partialResult[pair.key] = TodoReference( From d8d5918009d109407d05d429bc79b54c7e33d48c Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 30 Mar 2026 21:56:03 +0900 Subject: [PATCH 26/29] =?UTF-8?q?fix:=20=EC=82=AC=EC=9A=A9=EC=9E=90=20?= =?UTF-8?q?=EC=BB=A4=EC=8A=A4=ED=85=80=20=EC=B9=B4=ED=85=8C=EA=B3=A0?= =?UTF-8?q?=EB=A6=AC=20=EB=81=BC=EB=A6=AC=EB=8A=94=20=EA=B2=80=EC=82=AC?= =?UTF-8?q?=ED=95=98=EC=A7=80=20=EC=95=8A=EC=95=84=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ViewModel/TodoManageViewModel.swift | 23 ++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/DevLog/Presentation/ViewModel/TodoManageViewModel.swift b/DevLog/Presentation/ViewModel/TodoManageViewModel.swift index d717bc4b..de8f0f61 100644 --- a/DevLog/Presentation/ViewModel/TodoManageViewModel.swift +++ b/DevLog/Presentation/ViewModel/TodoManageViewModel.swift @@ -66,14 +66,31 @@ final class TodoManageViewModel: Store { } var canSubmitUserCategory: Bool { - let trimmedCategoryName = state.category?.name.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard let currentCategory = state.category else { return false } + let trimmedCategoryName = currentCategory.name.trimmingCharacters(in: .whitespacesAndNewlines) if trimmedCategoryName.isEmpty { return false } - return !SystemTodoCategory.allCases.contains { - $0.localizedName.localizedCaseInsensitiveCompare(trimmedCategoryName) == .orderedSame + // 시스템 카테고리와 이름 중복 확인 + if SystemTodoCategory.allCases.contains(where: { + $0.localizedName.caseInsensitiveCompare(trimmedCategoryName) == .orderedSame } + ) { + return false } + + // 다른 사용자 카테고리와 이름 중복 확인 + if state.preferences.contains(where: { preference in + guard case .user(let userCategory) = preference.category, + userCategory.id != currentCategory.id else { + return false + } + return userCategory.name.caseInsensitiveCompare(trimmedCategoryName) == .orderedSame + }) { + return false + } + + return true } init(_ preferences: [TodoCategoryPreference]) { From 0c363408529cf8f24df19720ead54c2b18473644 Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 30 Mar 2026 22:54:28 +0900 Subject: [PATCH 27/29] =?UTF-8?q?refactor:=20=EA=B0=81=20=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=96=B4=EB=B3=84=20=EB=AA=A8=EB=8D=B8=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=EC=B2=98=20=EB=AA=85=ED=99=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Entity/TodoCategoryPreference.swift | 13 ++ DevLog/Domain/Entity/TodoReference.swift | 2 +- .../Extension/TodoCategory+Presentation.swift | 61 ------ .../UserTodoCategory+Presentation.swift | 25 --- .../Structure/PushNotificationItem.swift | 8 + .../Structure/RecentTodoItem.swift | 8 + .../SystemTodoCategoryItem.swift} | 24 ++- .../Structure/TodayTodoItem.swift | 8 + .../Structure/TodoCategoryPreference.swift | 12 -- .../TodoCategoryPreferenceItem.swift | 90 +++++++++ .../Structure/TodoReferenceItem.swift | 20 ++ .../Structure/UserTodoCategoryItem.swift | 32 ++++ .../ViewModel/HomeViewModel.swift | 20 +- .../ViewModel/TodoDetailViewModel.swift | 7 +- .../ViewModel/TodoEditorViewModel.swift | 31 +-- .../ViewModel/TodoManageViewModel.swift | 176 ++++++++++-------- DevLog/UI/Common/TodoDetailContentView.swift | 2 +- .../UI/Common/TodoMarkdownContentView.swift | 4 +- DevLog/UI/Home/HomeView.swift | 37 ++-- DevLog/UI/Home/TodoDetailView.swift | 2 +- DevLog/UI/Home/TodoEditorView.swift | 18 +- DevLog/UI/Home/TodoListView.swift | 8 +- DevLog/UI/Home/TodoManageView.swift | 65 ++++--- DevLog/UI/Profile/ProfileView.swift | 5 +- .../PushNotificationListView.swift | 6 +- DevLog/UI/Today/TodayView.swift | 9 +- 26 files changed, 411 insertions(+), 282 deletions(-) create mode 100644 DevLog/Domain/Entity/TodoCategoryPreference.swift delete mode 100644 DevLog/Presentation/Extension/TodoCategory+Presentation.swift delete mode 100644 DevLog/Presentation/Extension/UserTodoCategory+Presentation.swift rename DevLog/Presentation/{Extension/SystemTodoCategory+Presentation.swift => Structure/SystemTodoCategoryItem.swift} (79%) delete mode 100644 DevLog/Presentation/Structure/TodoCategoryPreference.swift create mode 100644 DevLog/Presentation/Structure/TodoCategoryPreferenceItem.swift create mode 100644 DevLog/Presentation/Structure/TodoReferenceItem.swift create mode 100644 DevLog/Presentation/Structure/UserTodoCategoryItem.swift diff --git a/DevLog/Domain/Entity/TodoCategoryPreference.swift b/DevLog/Domain/Entity/TodoCategoryPreference.swift new file mode 100644 index 00000000..1b3f337d --- /dev/null +++ b/DevLog/Domain/Entity/TodoCategoryPreference.swift @@ -0,0 +1,13 @@ +// +// TodoCategoryPreference.swift +// DevLog +// +// Created by opfic on 3/30/26. +// + +import Foundation + +struct TodoCategoryPreference: Equatable { + let category: TodoCategory + var isVisible: Bool +} diff --git a/DevLog/Domain/Entity/TodoReference.swift b/DevLog/Domain/Entity/TodoReference.swift index cedd6a02..3b8a7470 100644 --- a/DevLog/Domain/Entity/TodoReference.swift +++ b/DevLog/Domain/Entity/TodoReference.swift @@ -7,7 +7,7 @@ import Foundation -struct TodoReference: Equatable { +struct TodoReference { let id: String let title: String let category: TodoCategory diff --git a/DevLog/Presentation/Extension/TodoCategory+Presentation.swift b/DevLog/Presentation/Extension/TodoCategory+Presentation.swift deleted file mode 100644 index 7fd87c06..00000000 --- a/DevLog/Presentation/Extension/TodoCategory+Presentation.swift +++ /dev/null @@ -1,61 +0,0 @@ -// -// TodoCategory+Presentation.swift -// DevLog -// -// Created by opfic on 3/29/26. -// - -import SwiftUI - -extension TodoCategory: Identifiable { - var id: String { - switch self { - case .system(let category): - return category.id - case .user(let category): - return category.id - } - } -} - -extension TodoCategory: Hashable { - func hash(into hasher: inout Hasher) { - switch self { - case .system(let category): - hasher.combine(0) - hasher.combine(category) - case .user(let category): - hasher.combine(1) - hasher.combine(category) - } - } -} - -extension TodoCategory { - var symbolName: String { - switch self { - case .system(let category): - return category.symbolName - case .user(let category): - return category.symbolName - } - } - - var localizedName: String { - switch self { - case .system(let category): - return category.localizedName - case .user(let category): - return category.localizedName - } - } - - var color: Color { - switch self { - case .system(let category): - return category.color - case .user(let category): - return category.color - } - } -} diff --git a/DevLog/Presentation/Extension/UserTodoCategory+Presentation.swift b/DevLog/Presentation/Extension/UserTodoCategory+Presentation.swift deleted file mode 100644 index 334357d4..00000000 --- a/DevLog/Presentation/Extension/UserTodoCategory+Presentation.swift +++ /dev/null @@ -1,25 +0,0 @@ -// -// UserTodoCategory+Presentation.swift -// DevLog -// -// Created by opfic on 3/29/26. -// - -import SwiftUI - -extension UserTodoCategory: Identifiable { } - -extension UserTodoCategory: Hashable { - func hash(into hasher: inout Hasher) { - hasher.combine(id) - hasher.combine(colorHex) - } -} - -extension UserTodoCategory { - var symbolName: String { "tray.fill" } - - var localizedName: String { name } - - var color: Color { Color(hexString: colorHex) ?? .gray } -} diff --git a/DevLog/Presentation/Structure/PushNotificationItem.swift b/DevLog/Presentation/Structure/PushNotificationItem.swift index dd8273fb..cce0d540 100644 --- a/DevLog/Presentation/Structure/PushNotificationItem.swift +++ b/DevLog/Presentation/Structure/PushNotificationItem.swift @@ -25,4 +25,12 @@ struct PushNotificationItem: Identifiable, Hashable { self.todoId = notification.todoId self.todoCategory = notification.todoCategory } + + static func == (lhs: PushNotificationItem, rhs: PushNotificationItem) -> Bool { + lhs.id == rhs.id + } + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } } diff --git a/DevLog/Presentation/Structure/RecentTodoItem.swift b/DevLog/Presentation/Structure/RecentTodoItem.swift index 57ac2cdc..0d972555 100644 --- a/DevLog/Presentation/Structure/RecentTodoItem.swift +++ b/DevLog/Presentation/Structure/RecentTodoItem.swift @@ -25,4 +25,12 @@ struct RecentTodoItem: Identifiable, Hashable { self.tags = todo.tags self.category = todo.category } + + static func == (lhs: RecentTodoItem, rhs: RecentTodoItem) -> Bool { + lhs.id == rhs.id + } + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } } diff --git a/DevLog/Presentation/Extension/SystemTodoCategory+Presentation.swift b/DevLog/Presentation/Structure/SystemTodoCategoryItem.swift similarity index 79% rename from DevLog/Presentation/Extension/SystemTodoCategory+Presentation.swift rename to DevLog/Presentation/Structure/SystemTodoCategoryItem.swift index 7320f2be..0a9da6c6 100644 --- a/DevLog/Presentation/Extension/SystemTodoCategory+Presentation.swift +++ b/DevLog/Presentation/Structure/SystemTodoCategoryItem.swift @@ -1,25 +1,23 @@ // -// SystemTodoCategory+Presentation.swift +// SystemTodoCategoryItem.swift // DevLog // -// Created by opfic on 3/29/26. +// Created by opfic on 3/30/26. // import SwiftUI -extension SystemTodoCategory: Identifiable { - var id: String { rawValue } -} +struct SystemTodoCategoryItem: Identifiable, Hashable { + let systemTodoCategory: SystemTodoCategory -extension SystemTodoCategory: Hashable { - func hash(into hasher: inout Hasher) { - hasher.combine(rawValue) + init(from systemTodoCategory: SystemTodoCategory) { + self.systemTodoCategory = systemTodoCategory } -} -extension SystemTodoCategory { + var id: String { systemTodoCategory.rawValue } + var symbolName: String { - switch self { + switch systemTodoCategory { case .issue: return "exclamationmark.triangle" case .feature: return "sparkles" case .improvement: return "arrow.triangle.2.circlepath" @@ -32,7 +30,7 @@ extension SystemTodoCategory { } var localizedName: String { - switch self { + switch systemTodoCategory { case .issue: return NSLocalizedString("todo_category_issue", comment: "Todo category: Issue") case .feature: return NSLocalizedString("todo_category_feature", comment: "Todo category: Feature") case .improvement: return NSLocalizedString("todo_category_improvement", comment: "Todo category: Improvement") @@ -45,7 +43,7 @@ extension SystemTodoCategory { } var color: Color { - switch self { + switch systemTodoCategory { case .issue: return .red case .feature: return .green case .improvement: return .cyan diff --git a/DevLog/Presentation/Structure/TodayTodoItem.swift b/DevLog/Presentation/Structure/TodayTodoItem.swift index 7afd16cc..7e583bbc 100644 --- a/DevLog/Presentation/Structure/TodayTodoItem.swift +++ b/DevLog/Presentation/Structure/TodayTodoItem.swift @@ -27,4 +27,12 @@ struct TodayTodoItem: Identifiable, Hashable { self.dueDate = todo.dueDate self.category = todo.category } + + static func == (lhs: TodayTodoItem, rhs: TodayTodoItem) -> Bool { + lhs.id == rhs.id + } + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } } diff --git a/DevLog/Presentation/Structure/TodoCategoryPreference.swift b/DevLog/Presentation/Structure/TodoCategoryPreference.swift deleted file mode 100644 index 0b3264f2..00000000 --- a/DevLog/Presentation/Structure/TodoCategoryPreference.swift +++ /dev/null @@ -1,12 +0,0 @@ -// -// TodoCategoryPreference.swift -// DevLog -// -// Created by 최윤진 on 1/2/26. -// - -struct TodoCategoryPreference: Identifiable, Equatable { - var id: String { category.id } - let category: TodoCategory - var isVisible: Bool -} diff --git a/DevLog/Presentation/Structure/TodoCategoryPreferenceItem.swift b/DevLog/Presentation/Structure/TodoCategoryPreferenceItem.swift new file mode 100644 index 00000000..a8415d9b --- /dev/null +++ b/DevLog/Presentation/Structure/TodoCategoryPreferenceItem.swift @@ -0,0 +1,90 @@ +// +// TodoCategoryPreferenceItem.swift +// DevLog +// +// Created by opfic on 3/30/26. +// + +import SwiftUI + +struct TodoCategoryPreferenceItem: Identifiable, Hashable { + var category: TodoCategory + var isVisible: Bool + + init(from preference: TodoCategoryPreference) { + self.category = preference.category + self.isVisible = preference.isVisible + } + + init( + from category: TodoCategory, + isVisible: Bool = true + ) { + self.category = category + self.isVisible = isVisible + } + + var id: String { category.storageValue } + + var todoCategory: TodoCategory { category } + + var preference: TodoCategoryPreference { + TodoCategoryPreference( + category: category, + isVisible: isVisible + ) + } + + var isUserCategory: Bool { + if case .user = category { + return true + } + + return false + } + + var symbolName: String { + switch category { + case .system(let systemTodoCategory): + return SystemTodoCategoryItem(from: systemTodoCategory).symbolName + case .user(let userTodoCategory): + return UserTodoCategoryItem(from: userTodoCategory).symbolName + } + } + + var localizedName: String { + switch category { + case .system(let systemTodoCategory): + return SystemTodoCategoryItem(from: systemTodoCategory).localizedName + case .user(let userTodoCategory): + return UserTodoCategoryItem(from: userTodoCategory).localizedName + } + } + + var color: Color { + switch category { + case .system(let systemTodoCategory): + return SystemTodoCategoryItem(from: systemTodoCategory).color + case .user(let userTodoCategory): + return UserTodoCategoryItem(from: userTodoCategory).color + } + } + + static func == (lhs: TodoCategoryPreferenceItem, rhs: TodoCategoryPreferenceItem) -> Bool { + lhs.category == rhs.category && lhs.isVisible == rhs.isVisible + } + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + + switch category { + case .system(let systemTodoCategory): + hasher.combine(systemTodoCategory.rawValue) + case .user(let userTodoCategory): + hasher.combine(userTodoCategory.name) + hasher.combine(userTodoCategory.colorHex) + } + + hasher.combine(isVisible) + } +} diff --git a/DevLog/Presentation/Structure/TodoReferenceItem.swift b/DevLog/Presentation/Structure/TodoReferenceItem.swift new file mode 100644 index 00000000..32d5c959 --- /dev/null +++ b/DevLog/Presentation/Structure/TodoReferenceItem.swift @@ -0,0 +1,20 @@ +// +// TodoReferenceItem.swift +// DevLog +// +// Created by opfic on 3/30/26. +// + +import Foundation + +struct TodoReferenceItem: Equatable { + let id: String + let title: String + let category: TodoCategoryPreferenceItem + + init(from todoReference: TodoReference) { + self.id = todoReference.id + self.title = todoReference.title + self.category = TodoCategoryPreferenceItem(from: todoReference.category) + } +} diff --git a/DevLog/Presentation/Structure/UserTodoCategoryItem.swift b/DevLog/Presentation/Structure/UserTodoCategoryItem.swift new file mode 100644 index 00000000..cabd7b82 --- /dev/null +++ b/DevLog/Presentation/Structure/UserTodoCategoryItem.swift @@ -0,0 +1,32 @@ +// +// UserTodoCategoryItem.swift +// DevLog +// +// Created by opfic on 3/30/26. +// + +import SwiftUI + +struct UserTodoCategoryItem: Identifiable, Hashable { + let userTodoCategory: UserTodoCategory + + init(from userTodoCategory: UserTodoCategory) { + self.userTodoCategory = userTodoCategory + } + + var id: String { userTodoCategory.id } + + var symbolName: String { "tray.fill" } + + var localizedName: String { userTodoCategory.name } + + var color: Color { Color(hexString: userTodoCategory.colorHex) ?? .gray } + + static func == (lhs: UserTodoCategoryItem, rhs: UserTodoCategoryItem) -> Bool { + lhs.id == rhs.id + } + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } +} diff --git a/DevLog/Presentation/ViewModel/HomeViewModel.swift b/DevLog/Presentation/ViewModel/HomeViewModel.swift index 4e6c3e35..b5c7b314 100644 --- a/DevLog/Presentation/ViewModel/HomeViewModel.swift +++ b/DevLog/Presentation/ViewModel/HomeViewModel.swift @@ -11,7 +11,7 @@ import Combine @Observable final class HomeViewModel: Store { struct State: Equatable { - var preferences: [TodoCategoryPreference] = [] + var preferences: [TodoCategoryPreferenceItem] = [] var recentTodos: [RecentTodoItem] = [] var webPages: [WebPageItem] = [] var isNetworkConnected: Bool = true @@ -42,8 +42,8 @@ final class HomeViewModel: Store { case setToast(isPresented: Bool, type: ToastType? = nil) case setLoading(LoadingTarget, Bool) case tapTodoCategory(TodoCategory) - case orderTodoCategoryPreferences([TodoCategoryPreference]) - case setTodoCategoryPreferences([TodoCategoryPreference]) + case orderTodoCategoryPreferences([TodoCategoryPreferenceItem]) + case setTodoCategoryPreferences([TodoCategoryPreferenceItem]) case addTodo(Todo) case updateRecentTodos([RecentTodoItem]) case updateWebPageURLInput(String) @@ -60,7 +60,7 @@ final class HomeViewModel: Store { case deleteWebPage(WebPageItem, Int) case undoDeleteWebPage(String) case fetchTodoCategoryPreferences - case updateTodoCategoryPreferences([TodoCategoryPreference]) + case updateTodoCategoryPreferences([TodoCategoryPreferenceItem]) case fetchRecentTodos case fetchWebPages case showModalAfterDelay(ModalType) @@ -162,7 +162,7 @@ final class HomeViewModel: Store { do { defer { endLoading(for: .preferences, mode: .immediate) } let preferences = try await fetchPreferencesUseCase.execute() - send(.setTodoCategoryPreferences(preferences)) + send(.setTodoCategoryPreferences(preferences.map(TodoCategoryPreferenceItem.init(from:)))) } catch { send(.setAlert(isPresented: true, type: .error)) } @@ -170,7 +170,7 @@ final class HomeViewModel: Store { case .updateTodoCategoryPreferences(let items): Task { do { - try await updatePreferencesUseCase.execute(items) + try await updatePreferencesUseCase.execute(items.map(\.preference)) } catch { send(.setAlert(isPresented: true, type: .error)) } @@ -441,17 +441,17 @@ private extension HomeViewModel { func syncRecentTodos( _ recentTodos: [RecentTodoItem], - preferences: [TodoCategoryPreference] + preferences: [TodoCategoryPreferenceItem] ) -> [RecentTodoItem] { recentTodos.map { recentTodo in - guard let category = preferences.first(where: { + guard let item = preferences.first(where: { $0.category.storageValue == recentTodo.category.storageValue - })?.category else { + }) else { return recentTodo } var recentTodo = recentTodo - recentTodo.category = category + recentTodo.category = item.category return recentTodo } } diff --git a/DevLog/Presentation/ViewModel/TodoDetailViewModel.swift b/DevLog/Presentation/ViewModel/TodoDetailViewModel.swift index ceb369ce..cfd9a71c 100644 --- a/DevLog/Presentation/ViewModel/TodoDetailViewModel.swift +++ b/DevLog/Presentation/ViewModel/TodoDetailViewModel.swift @@ -12,7 +12,7 @@ final class TodoDetailViewModel: Store { struct State: Equatable { var todo: Todo? var selectedTodoId: TodoIdItem? - var referenceItems: [Int: TodoReference] = [:] + var referenceItems: [Int: TodoReferenceItem] = [:] var isLoading: Bool = false var showAlert: Bool = false var showEditor: Bool = false @@ -28,7 +28,7 @@ final class TodoDetailViewModel: Store { case setShowInfo(Bool) case setSelectedTodoId(TodoIdItem?) case setTodo(Todo) - case setReferenceItems([Int: TodoReference]) + case setReferenceItems([Int: TodoReferenceItem]) case setLoading(Bool) case upsertTodo(Todo) } @@ -108,11 +108,12 @@ final class TodoDetailViewModel: Store { case .resolveMarkdown(let content): Task { let numbers = content.todoReferenceNumbers - var referenceItems = [Int: TodoReference]() + var referenceItems = [Int: TodoReferenceItem]() if !numbers.isEmpty { do { referenceItems = try await fetchReferenceItemsUseCase.execute(numbers) + .mapValues(TodoReferenceItem.init(from:)) } catch { referenceItems = [:] } diff --git a/DevLog/Presentation/ViewModel/TodoEditorViewModel.swift b/DevLog/Presentation/ViewModel/TodoEditorViewModel.swift index 5fa808d6..c9e25d46 100644 --- a/DevLog/Presentation/ViewModel/TodoEditorViewModel.swift +++ b/DevLog/Presentation/ViewModel/TodoEditorViewModel.swift @@ -39,7 +39,7 @@ final class TodoEditorViewModel: Store { self.content = state.content self.dueDate = state.dueDate self.tags = Array(state.tags) - self.category = state.category + self.category = state.category.category } } @@ -50,15 +50,15 @@ final class TodoEditorViewModel: Store { var selectedTodoId: TodoIdItem? var title: String = "" var content: String = "" - var referenceItems: [Int: TodoReference] = [:] + var referenceItems: [Int: TodoReferenceItem] = [:] var dueDate: Date? var showInfo: Bool = false var tags: OrderedSet = [] var tagText: String = "" var focusOnEditor: Bool = false var tabViewTag: Tag = .editor - var categories: [TodoCategory] = [] - var category: TodoCategory = .system(.etc) + var categories: [TodoCategoryPreferenceItem] = [] + var category = TodoCategoryPreferenceItem(from: .system(.etc)) var isValidToSave: Bool { !title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } @@ -75,15 +75,15 @@ final class TodoEditorViewModel: Store { case setContent(String) case setCompleted(Bool) case setDueDate(Date?) - case setCategory(TodoCategory) + case setCategory(TodoCategoryPreferenceItem) case setPinned(Bool) case setShowInfo(Bool) case setSelectedTodoId(TodoIdItem?) case setTabViewTag(Tag) case setTagText(String) case setTitle(String) - case setCategories([TodoCategory]) - case setReferenceItems([Int: TodoReference]) + case setCategories([TodoCategoryPreferenceItem]) + case setReferenceItems([Int: TodoReferenceItem]) } enum SideEffect { @@ -133,8 +133,8 @@ final class TodoEditorViewModel: Store { self.number = nil self.createdAt = nil self.originalDraft = nil - state.category = category - state.categories = [category] + state.category = TodoCategoryPreferenceItem(from: category) + state.categories = [TodoCategoryPreferenceItem(from: category)] } // 기존 Todo 편집용 생성자 @@ -158,7 +158,7 @@ final class TodoEditorViewModel: Store { state.content = todo.content state.dueDate = todo.dueDate state.tags = OrderedSet(todo.tags) - state.category = todo.category + state.category = TodoCategoryPreferenceItem(from: todo.category) } func reduce(with action: Action) -> [SideEffect] { @@ -192,8 +192,8 @@ final class TodoEditorViewModel: Store { state.completedAt = isCompleted ? Date() : nil } state.isCompleted = isCompleted - case .setCategory(let todoCategory): - state.category = todoCategory + case .setCategory(let todoCategoryItem): + state.category = todoCategoryItem case .setPinned(let isPinned): state.isPinned = isPinned case .setShowInfo(let isPresented): @@ -221,17 +221,18 @@ final class TodoEditorViewModel: Store { Task { do { let preferences = try await fetchPreferencesUseCase.execute() - send(.setCategories(preferences.map(\.category))) + send(.setCategories(preferences.map(TodoCategoryPreferenceItem.init(from:)))) } catch { } } case .resolveMarkdown(let content): Task { let numbers = content.todoReferenceNumbers - var referenceItems = [Int: TodoReference]() + var referenceItems = [Int: TodoReferenceItem]() if !numbers.isEmpty { do { referenceItems = try await fetchReferenceItemsUseCase.execute(numbers) + .mapValues(TodoReferenceItem.init(from:)) } catch { referenceItems = [:] } @@ -276,7 +277,7 @@ extension TodoEditorViewModel { completedAt: state.completedAt, dueDate: state.dueDate, tags: state.tags.map { $0 }, - category: state.category + category: state.category.category ) } } diff --git a/DevLog/Presentation/ViewModel/TodoManageViewModel.swift b/DevLog/Presentation/ViewModel/TodoManageViewModel.swift index de8f0f61..99a22519 100644 --- a/DevLog/Presentation/ViewModel/TodoManageViewModel.swift +++ b/DevLog/Presentation/ViewModel/TodoManageViewModel.swift @@ -10,8 +10,8 @@ import SwiftUI @Observable final class TodoManageViewModel: Store { struct State: Equatable { - var preferences: [TodoCategoryPreference] - var category: UserTodoCategory? + var preferences: [TodoCategoryPreferenceItem] + var category: TodoCategoryPreferenceItem? var showSheet: Bool = false var showAlert: Bool = false } @@ -19,9 +19,9 @@ final class TodoManageViewModel: Store { enum Action { case tapAddUserCategory case moveItem(from: IndexSet, target: Int) - case tapItem(_ item: TodoCategory) - case tapEditUserCategory(TodoCategoryPreference) - case tapDeleteUserCategory(TodoCategoryPreference) + case tapItem(TodoCategoryPreferenceItem) + case tapEditUserCategory(TodoCategoryPreferenceItem) + case tapDeleteUserCategory(TodoCategoryPreferenceItem) case confirmDeleteUserCategory case setShowSheet(Bool) case setShowAlert(Bool) @@ -36,17 +36,11 @@ final class TodoManageViewModel: Store { private(set) var state: State var isEditing: Bool { - guard let userTodoCategory = state.category else { + guard let categoryItem = state.category else { return false } - return state.preferences.contains { preference in - guard case .user(let currentCategory) = preference.category else { - return false - } - - return currentCategory.id == userTodoCategory.id - } + return state.preferences.contains { $0.id == categoryItem.id } } var navigationTitle: String { @@ -57,35 +51,54 @@ final class TodoManageViewModel: Store { isEditing ? "저장" : "추가" } - var placerholder: String { - state.category?.name ?? "이름" + var placeholder: String { + guard + let categoryItem = state.category, + case .user(let userTodoCategory) = categoryItem.category + else { + return "이름" + } + + return userTodoCategory.name } var categoryNameCountText: String { - "\((state.category?.name ?? "").count)/\(20)" + guard + let item = state.category, + case .user(let category) = item.category + else { + return "0/20" + } + + return "\(category.name.count)/20" } var canSubmitUserCategory: Bool { - guard let currentCategory = state.category else { return false } - let trimmedCategoryName = currentCategory.name.trimmingCharacters(in: .whitespacesAndNewlines) + guard + let item = state.category, + case .user(let category) = item.category + else { + return false + } + + let trimmedCategoryName = category.name.trimmingCharacters(in: .whitespacesAndNewlines) if trimmedCategoryName.isEmpty { return false } - // 시스템 카테고리와 이름 중복 확인 if SystemTodoCategory.allCases.contains(where: { - $0.localizedName.caseInsensitiveCompare(trimmedCategoryName) == .orderedSame } - ) { + SystemTodoCategoryItem(from: $0).localizedName.caseInsensitiveCompare(trimmedCategoryName) == .orderedSame + }) { return false } - // 다른 사용자 카테고리와 이름 중복 확인 - if state.preferences.contains(where: { preference in - guard case .user(let userCategory) = preference.category, - userCategory.id != currentCategory.id else { + if state.preferences.contains(where: { item in + guard case .user(let userTodoCategory) = item.category, + userTodoCategory.id != category.id else { return false } - return userCategory.name.caseInsensitiveCompare(trimmedCategoryName) == .orderedSame + + return userTodoCategory.name.caseInsensitiveCompare(trimmedCategoryName) == .orderedSame }) { return false } @@ -93,7 +106,7 @@ final class TodoManageViewModel: Store { return true } - init(_ preferences: [TodoCategoryPreference]) { + init(_ preferences: [TodoCategoryPreferenceItem]) { self.state = State(preferences: preferences) } @@ -102,44 +115,46 @@ final class TodoManageViewModel: Store { switch action { case .tapAddUserCategory: - state.category = UserTodoCategory( - id: UUID().uuidString.lowercased(), - name: "", - colorHex: Color.randomValue.hexValue ?? "#000000" + guard let randomHexValue = Color.randomValue.hexValue else { + break + } + + state.category = TodoCategoryPreferenceItem( + from: .user( + UserTodoCategory( + id: UUID().uuidString.lowercased(), + name: "", + colorHex: randomHexValue + ) + ) ) state.showSheet = true case .moveItem(let from, let target): state.preferences.move(fromOffsets: from, toOffset: target) case .tapItem(let item): - if let index = state.preferences.firstIndex(where: { $0.category == item }) { + if let index = state.preferences.firstIndex(where: { $0.id == item.id }) { state.preferences[index].isVisible.toggle() } - case .tapEditUserCategory(let preference): - guard case .user(let userTodoCategory) = preference.category else { + case .tapEditUserCategory(let item): + guard item.isUserCategory else { break } - state.category = userTodoCategory + state.category = item state.showSheet = true - case .tapDeleteUserCategory(let preference): - guard case .user(let userTodoCategory) = preference.category else { + case .tapDeleteUserCategory(let item): + guard item.isUserCategory else { break } - state.category = userTodoCategory + state.category = item state.showAlert = true case .confirmDeleteUserCategory: - guard let userTodoCategory = state.category else { + guard let categoryItem = state.category else { break } - if let index = state.preferences.firstIndex(where: { - guard case .user(let currentCategory) = $0.category else { - return false - } - - return currentCategory.id == userTodoCategory.id - }) { + if let index = state.preferences.firstIndex(where: { $0.id == categoryItem.id }) { state.preferences.remove(at: index) } state.showAlert = false @@ -155,48 +170,53 @@ final class TodoManageViewModel: Store { state.category = nil } case .setCategoryName(let name): - guard var category = state.category else { break } + guard var item = state.category, + case .user(var category) = item.category else { + break + } + category.name = String(name.prefix(20)) - state.category = category + item.category = .user(category) + state.category = item case .setCategoryColor(let color): - guard var category = state.category else { break } + guard var item = state.category, + case .user(var category) = item.category, + let hexValue = color.hexValue else { + break + } - category.colorHex = color.hexValue ?? "#000000" - state.category = category + category.colorHex = hexValue + item.category = .user(category) + state.category = item case .setRandomCategoryColor: - guard var category = state.category else { break } + guard var item = state.category, + case .user(var category) = item.category, + let randomHexValue = Color.randomValue.hexValue else { + break + } - category.colorHex = Color.randomValue.hexValue ?? "#000000" - state.category = category + category.colorHex = randomHexValue + item.category = .user(category) + state.category = item case .saveUserCategory: - guard let category = state.category else { break } + guard var item = state.category, + case .user(let category) = item.category else { + break + } - let name = category.name.trimmingCharacters(in: .whitespacesAndNewlines) - let updatedCategory = UserTodoCategory( - id: category.id, - name: name, - colorHex: category.colorHex + item.category = .user( + UserTodoCategory( + id: category.id, + name: category.name.trimmingCharacters(in: .whitespacesAndNewlines), + colorHex: category.colorHex + ) ) - if let index = state.preferences.firstIndex(where: { - guard case .user(let currentCategory) = $0.category else { - return false - } - - return currentCategory.id == updatedCategory.id - }) { - let preference = state.preferences[index] - state.preferences[index] = TodoCategoryPreference( - category: .user(updatedCategory), - isVisible: preference.isVisible - ) + if let index = state.preferences.firstIndex(where: { $0.id == item.id }) { + item.isVisible = state.preferences[index].isVisible + state.preferences[index] = item } else { - state.preferences.append( - TodoCategoryPreference( - category: .user(updatedCategory), - isVisible: true - ) - ) + state.preferences.append(item) } state.showSheet = false diff --git a/DevLog/UI/Common/TodoDetailContentView.swift b/DevLog/UI/Common/TodoDetailContentView.swift index 802a2fa5..df685a60 100644 --- a/DevLog/UI/Common/TodoDetailContentView.swift +++ b/DevLog/UI/Common/TodoDetailContentView.swift @@ -11,7 +11,7 @@ import MarkdownUI struct TodoDetailContentView: View { let title: String let content: String - let referenceItems: [Int: TodoReference] + let referenceItems: [Int: TodoReferenceItem] var number: Int? var activityLabel: String? var onOpenTodoID: ((String) -> Void)? diff --git a/DevLog/UI/Common/TodoMarkdownContentView.swift b/DevLog/UI/Common/TodoMarkdownContentView.swift index 5623138a..c6803644 100644 --- a/DevLog/UI/Common/TodoMarkdownContentView.swift +++ b/DevLog/UI/Common/TodoMarkdownContentView.swift @@ -15,7 +15,7 @@ private enum TodoMarkdownSection: Equatable { struct TodoMarkdownContentView: View { let content: String - let referenceItems: [Int: TodoReference] + let referenceItems: [Int: TodoReferenceItem] var onOpenTodoID: ((String) -> Void)? var body: some View { @@ -86,7 +86,7 @@ struct TodoMarkdownContentView: View { } private struct TodoReferenceRow: View { - let item: TodoReference + let item: TodoReferenceItem let number: Int var onOpenTodoID: ((String) -> Void)? diff --git a/DevLog/UI/Home/HomeView.swift b/DevLog/UI/Home/HomeView.swift index 451e54ba..e61daa65 100644 --- a/DevLog/UI/Home/HomeView.swift +++ b/DevLog/UI/Home/HomeView.swift @@ -24,14 +24,14 @@ struct HomeView: View { .navigationTitle("홈") .navigationDestination(for: Path.self) { path in switch path { - case .category(let todoCategory): + case .category(let item): TodoListView(viewModel: TodoListViewModel( fetchTodosUseCase: container.resolve(FetchTodosUseCase.self), fetchTodoByIdUseCase: container.resolve(FetchTodoByIdUseCase.self), upsertTodoUseCase: container.resolve(UpsertTodoUseCase.self), deleteTodoUseCase: container.resolve(DeleteTodoUseCase.self), undoDeleteTodoUseCase: container.resolve(UndoDeleteTodoUseCase.self), - category: todoCategory + category: item.todoCategory )) .environment(router) case .detail(let todoId): @@ -167,13 +167,12 @@ struct HomeView: View { LoadingView() } else { let preferences = viewModel.state.preferences - ForEach(preferences.filter { $0.isVisible }, id: \.id) { preference in - let category = preference.category - NavigationLink(value: Path.category(category)) { + ForEach(preferences.filter { $0.isVisible }, id: \.id) { item in + NavigationLink(value: Path.category(item)) { labelImage( - text: category.localizedName, - systemName: category.symbolName, - imageColor: category.color + text: item.localizedName, + systemName: item.symbolName, + imageColor: item.color ) } } @@ -298,17 +297,16 @@ struct HomeView: View { LoadingView() } else { let preferences = viewModel.state.preferences.filter(\.isVisible) - ForEach(preferences, id: \.id) { preference in - let category = preference.category + ForEach(preferences, id: \.id) { item in Button { DispatchQueue.main.async { - viewModel.send(.tapTodoCategory(category)) + viewModel.send(.tapTodoCategory(item.category)) } } label: { labelImage( - text: category.localizedName, - systemName: category.symbolName, - imageColor: category.color + text: item.localizedName, + systemName: item.symbolName, + imageColor: item.color ) } } @@ -372,7 +370,7 @@ struct HomeView: View { } private enum Path: Hashable { - case category(TodoCategory) + case category(TodoCategoryPreferenceItem) case detail(String) case web(WebPageItem) } @@ -383,12 +381,13 @@ private struct RecentTodoRow: View { let sceneWidth: CGFloat var body: some View { + let todoCategoryItem = TodoCategoryPreferenceItem(from: todo.category) HStack(alignment: .top, spacing: 12) { RoundedRectangle(cornerRadius: 8) - .fill(todo.category.color) + .fill(todoCategoryItem.color) .frame(width: sceneWidth * 0.08, height: sceneWidth * 0.08) .overlay { - Image(systemName: todo.category.symbolName) + Image(systemName: todoCategoryItem.symbolName) .foregroundStyle(Color.white) .font(.title3) } @@ -413,9 +412,9 @@ private struct RecentTodoRow: View { } HStack(spacing: 6) { - Text(todo.category.localizedName) + Text(todoCategoryItem.localizedName) .font(.caption.weight(.semibold)) - .foregroundStyle(todo.category.color) + .foregroundStyle(todoCategoryItem.color) RelativeTimeText(date: todo.updatedAt) } diff --git a/DevLog/UI/Home/TodoDetailView.swift b/DevLog/UI/Home/TodoDetailView.swift index 693f3ce7..31f914f7 100644 --- a/DevLog/UI/Home/TodoDetailView.swift +++ b/DevLog/UI/Home/TodoDetailView.swift @@ -118,7 +118,7 @@ private struct TodoDetailInfoSheetView: View { HStack { Text("카테고리") Spacer() - Text(todo.category.localizedName) + Text(TodoCategoryPreferenceItem(from: todo.category).localizedName) .foregroundStyle(.secondary) } diff --git a/DevLog/UI/Home/TodoEditorView.swift b/DevLog/UI/Home/TodoEditorView.swift index 7a2f1457..703341be 100644 --- a/DevLog/UI/Home/TodoEditorView.swift +++ b/DevLog/UI/Home/TodoEditorView.swift @@ -212,13 +212,21 @@ private struct TodoEditorInfoSheetView: View { Picker( "카테고리", selection: Binding( - get: { viewModel.state.category }, - set: { viewModel.send(.setCategory($0)) } + get: { viewModel.state.category.id }, + set: { categoryID in + guard let item = viewModel.state.categories.first(where: { + $0.id == categoryID + }) else { + return + } + + viewModel.send(.setCategory(item)) + } ) ) { - ForEach(viewModel.state.categories, id: \.id) { category in - Text(category.localizedName) - .tag(category) + ForEach(viewModel.state.categories, id: \.id) { item in + Text(item.localizedName) + .tag(item.id) } } diff --git a/DevLog/UI/Home/TodoListView.swift b/DevLog/UI/Home/TodoListView.swift index b98bcdc9..1a763c60 100644 --- a/DevLog/UI/Home/TodoListView.swift +++ b/DevLog/UI/Home/TodoListView.swift @@ -41,7 +41,7 @@ struct TodoListView: View { set: { viewModel.send(.setIsSearching($0)) } ), placement: .navigationBarDrawer(displayMode: .always), - prompt: "\(viewModel.state.category.localizedName) 검색" + prompt: "\(TodoCategoryPreferenceItem(from: viewModel.state.category).localizedName) 검색" ) } } @@ -76,7 +76,7 @@ struct TodoListView: View { ) { Label(viewModel.state.toastMessage, systemImage: "arrow.uturn.left") } - .navigationTitle(viewModel.state.category.localizedName) + .navigationTitle(TodoCategoryPreferenceItem(from: viewModel.state.category).localizedName) .fullScreenCover(isPresented: Binding( get: { viewModel.state.showEditor }, set: { viewModel.send(.setShowEditor($0)) } @@ -222,7 +222,7 @@ struct TodoListView: View { set: { viewModel.send(.setIsSearching($0)) } ), placement: .navigationBarDrawer(displayMode: .always), - prompt: "\(viewModel.state.category.localizedName) 검색" + prompt: "\(TodoCategoryPreferenceItem(from: viewModel.state.category).localizedName) 검색" ) } @@ -235,7 +235,7 @@ struct TodoListView: View { : Array(searchResults.prefix(limit)) if viewModel.state.searchText.isEmpty { - Text("\(viewModel.state.category.localizedName)의 제목이나 내용을 검색해 보세요.") + Text("\(TodoCategoryPreferenceItem(from: viewModel.state.category).localizedName)의 제목이나 내용을 검색해 보세요.") .foregroundStyle(Color.gray) .frame(maxWidth: .infinity) } else if viewModel.state.isLoading { diff --git a/DevLog/UI/Home/TodoManageView.swift b/DevLog/UI/Home/TodoManageView.swift index d71df66d..7cfe1a10 100644 --- a/DevLog/UI/Home/TodoManageView.swift +++ b/DevLog/UI/Home/TodoManageView.swift @@ -9,26 +9,25 @@ import SwiftUI struct TodoManageView: View { @State var viewModel: TodoManageViewModel - @State private var tmpText: String = "" - var onDismiss: (([TodoCategoryPreference]) -> Void)? + @State private var tmpText = "" + var onDismiss: (([TodoCategoryPreferenceItem]) -> Void)? var body: some View { NavigationStack { List { - ForEach(viewModel.state.preferences, id: \.id) { preference in - let category = preference.category + ForEach(viewModel.state.preferences, id: \.id) { item in HStack(spacing: 0) { - CheckBox(isChecked: preference.isVisible, font: .title3) + CheckBox(isChecked: item.isVisible, font: .title3) .padding(.horizontal) .onTapGesture { - viewModel.send(.tapItem(category)) + viewModel.send(.tapItem(item)) } - Text(category.localizedName) + Text(item.localizedName) .lineLimit(1) Spacer() - if case .user = category { + if item.isUserCategory { Button { - viewModel.send(.tapEditUserCategory(preference)) + viewModel.send(.tapEditUserCategory(item)) } label: { Image(systemName: "slider.horizontal.3") } @@ -36,7 +35,7 @@ struct TodoManageView: View { .padding(.trailing, 8) Button(role: .destructive) { - viewModel.send(.tapDeleteUserCategory(preference)) + viewModel.send(.tapDeleteUserCategory(item)) } label: { Image(systemName: "trash") } @@ -45,14 +44,12 @@ struct TodoManageView: View { } } } - .onMove { (source: IndexSet, destination: Int) in + .onMove { source, destination in viewModel.send(.moveItem(from: source, target: destination)) } .listRowInsets(EdgeInsets()) } - // 편집 모드 활성화 - // row 우측에 line.3.horizontal 추가됨 - .environment(\.editMode, .constant(EditMode.active)) + .environment(\.editMode, .constant(.active)) .navigationTitle("TODO 편집") .navigationBarTitleDisplayMode(.inline) .navigationBarBackButtonHidden() @@ -89,9 +86,9 @@ struct TodoManageView: View { } ToolbarItem(placement: .navigationBarTrailing) { - Button(action: { + Button { onDismiss?(viewModel.state.preferences) - }) { + } label: { Text("완료") } } @@ -108,15 +105,15 @@ struct TodoManageView: View { TextField( "", text: $tmpText, - prompt: Text(viewModel.placerholder).foregroundStyle(.secondary) + prompt: Text(viewModel.placeholder).foregroundStyle(.secondary) ) .frame(height: UIFont.preferredFont(forTextStyle: .body).lineHeight) .onAppear { - tmpText = viewModel.state.category?.name ?? "" + tmpText = currentCategoryName } .onChange(of: tmpText) { _, value in viewModel.send(.setCategoryName(value)) - tmpText = viewModel.state.category?.name ?? "" + tmpText = currentCategoryName } Text(viewModel.categoryNameCountText) @@ -125,14 +122,14 @@ struct TodoManageView: View { .monospacedDigit() } } - + Section { - let color = Color(hexString: viewModel.state.category?.colorHex ?? "") ?? .randomValue + let color = Color(hexString: currentCategoryColorHex) ?? .randomValue ColorPicker(selection: Binding( get: { color }, set: { viewModel.send(.setCategoryColor($0)) } ), supportsOpacity: false) { - Text(viewModel.state.category?.colorHex ?? "#") + Text(currentCategoryColorHex.isEmpty ? "#" : currentCategoryColorHex) .overlay(alignment: .bottom) { Rectangle() .frame(height: 1) @@ -154,7 +151,7 @@ struct TodoManageView: View { viewModel.send(.setShowSheet(false)) } } - + ToolbarItem(placement: .navigationBarTrailing) { Button(viewModel.submitTitle) { viewModel.send(.saveUserCategory) @@ -164,4 +161,26 @@ struct TodoManageView: View { } } } + + private var currentCategoryName: String { + guard + let categoryItem = viewModel.state.category, + case .user(let userTodoCategory) = categoryItem.category + else { + return "" + } + + return userTodoCategory.name + } + + private var currentCategoryColorHex: String { + guard + let categoryItem = viewModel.state.category, + case .user(let userTodoCategory) = categoryItem.category + else { + return "" + } + + return userTodoCategory.colorHex + } } diff --git a/DevLog/UI/Profile/ProfileView.swift b/DevLog/UI/Profile/ProfileView.swift index c116f47c..a077bb93 100644 --- a/DevLog/UI/Profile/ProfileView.swift +++ b/DevLog/UI/Profile/ProfileView.swift @@ -332,9 +332,10 @@ struct ProfileView: View { Button { router.push(Path.activity(activity)) } label: { + let todoCategoryItem = TodoCategoryPreferenceItem(from: activity.todo.category) HStack(spacing: 8) { - Image(systemName: activity.todo.category.symbolName) - .foregroundStyle(activity.todo.category.color) + Image(systemName: todoCategoryItem.symbolName) + .foregroundStyle(todoCategoryItem.color) .frame(width: 20) Text(activity.todo.title) .font(.caption) diff --git a/DevLog/UI/PushNotification/PushNotificationListView.swift b/DevLog/UI/PushNotification/PushNotificationListView.swift index 7bff88b3..84036dbe 100644 --- a/DevLog/UI/PushNotification/PushNotificationListView.swift +++ b/DevLog/UI/PushNotification/PushNotificationListView.swift @@ -246,12 +246,12 @@ struct PushNotificationListView: View { private func notificationRow(_ item: PushNotificationItem) -> some View { HStack { VStack { - let todoCategory = item.todoCategory + let todoCategoryItem = TodoCategoryPreferenceItem(from: item.todoCategory) RoundedRectangle(cornerRadius: 8) - .fill(todoCategory.color) + .fill(todoCategoryItem.color) .frame(width: sceneWidth * 0.08, height: sceneWidth * 0.08) .overlay { - Image(systemName: todoCategory.symbolName) + Image(systemName: todoCategoryItem.symbolName) .foregroundStyle(Color.white) .font(.title3) } diff --git a/DevLog/UI/Today/TodayView.swift b/DevLog/UI/Today/TodayView.swift index 2f65bad8..4c639252 100644 --- a/DevLog/UI/Today/TodayView.swift +++ b/DevLog/UI/Today/TodayView.swift @@ -289,10 +289,11 @@ private struct TodayTodoRow: View { let item: TodayTodoItem var body: some View { + let todoCategoryItem = TodoCategoryPreferenceItem(from: item.category) VStack(alignment: .leading, spacing: 8) { HStack(spacing: 8) { - Image(systemName: item.category.symbolName) - .foregroundStyle(item.category.color) + Image(systemName: todoCategoryItem.symbolName) + .foregroundStyle(todoCategoryItem.color) .frame(width: 18) Text(item.title) .font(.headline) @@ -308,9 +309,9 @@ private struct TodayTodoRow: View { } HStack(spacing: 8) { - Text(item.category.localizedName) + Text(todoCategoryItem.localizedName) .font(.caption.weight(.semibold)) - .foregroundStyle(item.category.color) + .foregroundStyle(todoCategoryItem.color) if let dueDate { Text(dueDate.text) From 6a2922e116867423b54b17eac831a8460510fc3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=B5=9C=EC=9C=A4=EC=A7=84?= Date: Mon, 30 Mar 2026 23:08:06 +0900 Subject: [PATCH 28/29] =?UTF-8?q?fix:=20=EC=96=B8=EC=96=B4=EC=97=90=20?= =?UTF-8?q?=EB=94=B0=EB=9D=BC=20=EB=B9=84=EA=B5=90=EA=B0=92=EC=9D=B4=20?= =?UTF-8?q?=EB=8B=AC=EB=9D=BC=EC=A7=88=20=EC=88=98=20=EC=9E=88=EB=8A=94=20?= =?UTF-8?q?=EB=AC=B8=EC=A0=9C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- DevLog/Presentation/ViewModel/TodoManageViewModel.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DevLog/Presentation/ViewModel/TodoManageViewModel.swift b/DevLog/Presentation/ViewModel/TodoManageViewModel.swift index 99a22519..f6f71fb9 100644 --- a/DevLog/Presentation/ViewModel/TodoManageViewModel.swift +++ b/DevLog/Presentation/ViewModel/TodoManageViewModel.swift @@ -87,7 +87,7 @@ final class TodoManageViewModel: Store { } if SystemTodoCategory.allCases.contains(where: { - SystemTodoCategoryItem(from: $0).localizedName.caseInsensitiveCompare(trimmedCategoryName) == .orderedSame + $0.rawValue.caseInsensitiveCompare(trimmedCategoryName) == .orderedSame }) { return false } From 300f0a6cbe10a540eb3d66193b52e07c39391e63 Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 30 Mar 2026 23:46:01 +0900 Subject: [PATCH 29/29] refactor: TodoCategoryPreferenceItem -> TodoCategoryItem --- ...renceItem.swift => TodoCategoryItem.swift} | 6 +++--- .../Structure/TodoReferenceItem.swift | 4 ++-- .../ViewModel/HomeViewModel.swift | 20 +++++++++---------- .../ViewModel/TodoEditorViewModel.swift | 16 +++++++-------- .../ViewModel/TodoManageViewModel.swift | 14 ++++++------- DevLog/UI/Home/HomeView.swift | 14 ++++++------- DevLog/UI/Home/TodoDetailView.swift | 2 +- DevLog/UI/Home/TodoListView.swift | 8 ++++---- DevLog/UI/Home/TodoManageView.swift | 2 +- DevLog/UI/Profile/ProfileView.swift | 2 +- .../PushNotificationListView.swift | 2 +- DevLog/UI/Today/TodayView.swift | 2 +- 12 files changed, 46 insertions(+), 46 deletions(-) rename DevLog/Presentation/Structure/{TodoCategoryPreferenceItem.swift => TodoCategoryItem.swift} (92%) diff --git a/DevLog/Presentation/Structure/TodoCategoryPreferenceItem.swift b/DevLog/Presentation/Structure/TodoCategoryItem.swift similarity index 92% rename from DevLog/Presentation/Structure/TodoCategoryPreferenceItem.swift rename to DevLog/Presentation/Structure/TodoCategoryItem.swift index a8415d9b..4c3c4374 100644 --- a/DevLog/Presentation/Structure/TodoCategoryPreferenceItem.swift +++ b/DevLog/Presentation/Structure/TodoCategoryItem.swift @@ -1,5 +1,5 @@ // -// TodoCategoryPreferenceItem.swift +// TodoCategoryItem.swift // DevLog // // Created by opfic on 3/30/26. @@ -7,7 +7,7 @@ import SwiftUI -struct TodoCategoryPreferenceItem: Identifiable, Hashable { +struct TodoCategoryItem: Identifiable, Hashable { var category: TodoCategory var isVisible: Bool @@ -70,7 +70,7 @@ struct TodoCategoryPreferenceItem: Identifiable, Hashable { } } - static func == (lhs: TodoCategoryPreferenceItem, rhs: TodoCategoryPreferenceItem) -> Bool { + static func == (lhs: TodoCategoryItem, rhs: TodoCategoryItem) -> Bool { lhs.category == rhs.category && lhs.isVisible == rhs.isVisible } diff --git a/DevLog/Presentation/Structure/TodoReferenceItem.swift b/DevLog/Presentation/Structure/TodoReferenceItem.swift index 32d5c959..39440d02 100644 --- a/DevLog/Presentation/Structure/TodoReferenceItem.swift +++ b/DevLog/Presentation/Structure/TodoReferenceItem.swift @@ -10,11 +10,11 @@ import Foundation struct TodoReferenceItem: Equatable { let id: String let title: String - let category: TodoCategoryPreferenceItem + let category: TodoCategoryItem init(from todoReference: TodoReference) { self.id = todoReference.id self.title = todoReference.title - self.category = TodoCategoryPreferenceItem(from: todoReference.category) + self.category = TodoCategoryItem(from: todoReference.category) } } diff --git a/DevLog/Presentation/ViewModel/HomeViewModel.swift b/DevLog/Presentation/ViewModel/HomeViewModel.swift index b5c7b314..a906c21a 100644 --- a/DevLog/Presentation/ViewModel/HomeViewModel.swift +++ b/DevLog/Presentation/ViewModel/HomeViewModel.swift @@ -11,7 +11,7 @@ import Combine @Observable final class HomeViewModel: Store { struct State: Equatable { - var preferences: [TodoCategoryPreferenceItem] = [] + var preferences: [TodoCategoryItem] = [] var recentTodos: [RecentTodoItem] = [] var webPages: [WebPageItem] = [] var isNetworkConnected: Bool = true @@ -42,8 +42,8 @@ final class HomeViewModel: Store { case setToast(isPresented: Bool, type: ToastType? = nil) case setLoading(LoadingTarget, Bool) case tapTodoCategory(TodoCategory) - case orderTodoCategoryPreferences([TodoCategoryPreferenceItem]) - case setTodoCategoryPreferences([TodoCategoryPreferenceItem]) + case orderTodoCategory([TodoCategoryItem]) + case setTodoCategory([TodoCategoryItem]) case addTodo(Todo) case updateRecentTodos([RecentTodoItem]) case updateWebPageURLInput(String) @@ -60,7 +60,7 @@ final class HomeViewModel: Store { case deleteWebPage(WebPageItem, Int) case undoDeleteWebPage(String) case fetchTodoCategoryPreferences - case updateTodoCategoryPreferences([TodoCategoryPreferenceItem]) + case updateTodoCategoryPreferences([TodoCategoryItem]) case fetchRecentTodos case fetchWebPages case showModalAfterDelay(ModalType) @@ -141,11 +141,11 @@ final class HomeViewModel: Store { case .networkStatusChanged(let isConnected): state.isNetworkConnected = isConnected case .onAppear, .setPresentation, .setAlert, .setToast, .tapTodoCategory, - .orderTodoCategoryPreferences, .addTodo, .updateWebPageURLInput, + .orderTodoCategory, .addTodo, .updateWebPageURLInput, .addWebPage, .deleteWebPage, .undoDeleteWebPage: effects = reduceByView(action, state: &state) - case .setLoading, .setTodoCategoryPreferences, .updateRecentTodos, + case .setLoading, .setTodoCategory, .updateRecentTodos, .updateWebPages, .restoreWebPage: effects = reduceByRun(action, state: &state) } @@ -162,7 +162,7 @@ final class HomeViewModel: Store { do { defer { endLoading(for: .preferences, mode: .immediate) } let preferences = try await fetchPreferencesUseCase.execute() - send(.setTodoCategoryPreferences(preferences.map(TodoCategoryPreferenceItem.init(from:)))) + send(.setTodoCategory(preferences.map(TodoCategoryItem.init(from:)))) } catch { send(.setAlert(isPresented: true, type: .error)) } @@ -302,7 +302,7 @@ private extension HomeViewModel { state.selectedTodoCategory = category state.showContentPicker = false return [.showModalAfterDelay(.todoEditor)] - case .orderTodoCategoryPreferences(let preferences): + case .orderTodoCategory(let preferences): state.preferences = preferences state.recentTodos = syncRecentTodos(state.recentTodos, preferences: preferences) return [.updateTodoCategoryPreferences(preferences)] @@ -339,7 +339,7 @@ private extension HomeViewModel { switch action { case .setLoading(let loadingTarget, let isLoading): setLoading(&state, loadingTarget: loadingTarget, isLoading: isLoading) - case .setTodoCategoryPreferences(let preferences): + case .setTodoCategory(let preferences): state.preferences = preferences state.recentTodos = syncRecentTodos(state.recentTodos, preferences: preferences) case .updateRecentTodos(let todos): @@ -441,7 +441,7 @@ private extension HomeViewModel { func syncRecentTodos( _ recentTodos: [RecentTodoItem], - preferences: [TodoCategoryPreferenceItem] + preferences: [TodoCategoryItem] ) -> [RecentTodoItem] { recentTodos.map { recentTodo in guard let item = preferences.first(where: { diff --git a/DevLog/Presentation/ViewModel/TodoEditorViewModel.swift b/DevLog/Presentation/ViewModel/TodoEditorViewModel.swift index c9e25d46..26cca445 100644 --- a/DevLog/Presentation/ViewModel/TodoEditorViewModel.swift +++ b/DevLog/Presentation/ViewModel/TodoEditorViewModel.swift @@ -57,8 +57,8 @@ final class TodoEditorViewModel: Store { var tagText: String = "" var focusOnEditor: Bool = false var tabViewTag: Tag = .editor - var categories: [TodoCategoryPreferenceItem] = [] - var category = TodoCategoryPreferenceItem(from: .system(.etc)) + var categories: [TodoCategoryItem] = [] + var category = TodoCategoryItem(from: .system(.etc)) var isValidToSave: Bool { !title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } @@ -75,14 +75,14 @@ final class TodoEditorViewModel: Store { case setContent(String) case setCompleted(Bool) case setDueDate(Date?) - case setCategory(TodoCategoryPreferenceItem) + case setCategory(TodoCategoryItem) case setPinned(Bool) case setShowInfo(Bool) case setSelectedTodoId(TodoIdItem?) case setTabViewTag(Tag) case setTagText(String) case setTitle(String) - case setCategories([TodoCategoryPreferenceItem]) + case setCategories([TodoCategoryItem]) case setReferenceItems([Int: TodoReferenceItem]) } @@ -133,8 +133,8 @@ final class TodoEditorViewModel: Store { self.number = nil self.createdAt = nil self.originalDraft = nil - state.category = TodoCategoryPreferenceItem(from: category) - state.categories = [TodoCategoryPreferenceItem(from: category)] + state.category = TodoCategoryItem(from: category) + state.categories = [TodoCategoryItem(from: category)] } // 기존 Todo 편집용 생성자 @@ -158,7 +158,7 @@ final class TodoEditorViewModel: Store { state.content = todo.content state.dueDate = todo.dueDate state.tags = OrderedSet(todo.tags) - state.category = TodoCategoryPreferenceItem(from: todo.category) + state.category = TodoCategoryItem(from: todo.category) } func reduce(with action: Action) -> [SideEffect] { @@ -221,7 +221,7 @@ final class TodoEditorViewModel: Store { Task { do { let preferences = try await fetchPreferencesUseCase.execute() - send(.setCategories(preferences.map(TodoCategoryPreferenceItem.init(from:)))) + send(.setCategories(preferences.map(TodoCategoryItem.init(from:)))) } catch { } } case .resolveMarkdown(let content): diff --git a/DevLog/Presentation/ViewModel/TodoManageViewModel.swift b/DevLog/Presentation/ViewModel/TodoManageViewModel.swift index f6f71fb9..4feffe8d 100644 --- a/DevLog/Presentation/ViewModel/TodoManageViewModel.swift +++ b/DevLog/Presentation/ViewModel/TodoManageViewModel.swift @@ -10,8 +10,8 @@ import SwiftUI @Observable final class TodoManageViewModel: Store { struct State: Equatable { - var preferences: [TodoCategoryPreferenceItem] - var category: TodoCategoryPreferenceItem? + var preferences: [TodoCategoryItem] + var category: TodoCategoryItem? var showSheet: Bool = false var showAlert: Bool = false } @@ -19,9 +19,9 @@ final class TodoManageViewModel: Store { enum Action { case tapAddUserCategory case moveItem(from: IndexSet, target: Int) - case tapItem(TodoCategoryPreferenceItem) - case tapEditUserCategory(TodoCategoryPreferenceItem) - case tapDeleteUserCategory(TodoCategoryPreferenceItem) + case tapItem(TodoCategoryItem) + case tapEditUserCategory(TodoCategoryItem) + case tapDeleteUserCategory(TodoCategoryItem) case confirmDeleteUserCategory case setShowSheet(Bool) case setShowAlert(Bool) @@ -106,7 +106,7 @@ final class TodoManageViewModel: Store { return true } - init(_ preferences: [TodoCategoryPreferenceItem]) { + init(_ preferences: [TodoCategoryItem]) { self.state = State(preferences: preferences) } @@ -119,7 +119,7 @@ final class TodoManageViewModel: Store { break } - state.category = TodoCategoryPreferenceItem( + state.category = TodoCategoryItem( from: .user( UserTodoCategory( id: UUID().uuidString.lowercased(), diff --git a/DevLog/UI/Home/HomeView.swift b/DevLog/UI/Home/HomeView.swift index e61daa65..968bbf37 100644 --- a/DevLog/UI/Home/HomeView.swift +++ b/DevLog/UI/Home/HomeView.swift @@ -64,7 +64,7 @@ struct HomeView: View { onDismiss: { array in viewModel.send(.setPresentation(.reorderTodo, false)) withAnimation { - viewModel.send(.orderTodoCategoryPreferences(array)) + viewModel.send(.orderTodoCategory(array)) } } ) @@ -370,7 +370,7 @@ struct HomeView: View { } private enum Path: Hashable { - case category(TodoCategoryPreferenceItem) + case category(TodoCategoryItem) case detail(String) case web(WebPageItem) } @@ -381,13 +381,13 @@ private struct RecentTodoRow: View { let sceneWidth: CGFloat var body: some View { - let todoCategoryItem = TodoCategoryPreferenceItem(from: todo.category) + let category = TodoCategoryItem(from: todo.category) HStack(alignment: .top, spacing: 12) { RoundedRectangle(cornerRadius: 8) - .fill(todoCategoryItem.color) + .fill(category.color) .frame(width: sceneWidth * 0.08, height: sceneWidth * 0.08) .overlay { - Image(systemName: todoCategoryItem.symbolName) + Image(systemName: category.symbolName) .foregroundStyle(Color.white) .font(.title3) } @@ -412,9 +412,9 @@ private struct RecentTodoRow: View { } HStack(spacing: 6) { - Text(todoCategoryItem.localizedName) + Text(category.localizedName) .font(.caption.weight(.semibold)) - .foregroundStyle(todoCategoryItem.color) + .foregroundStyle(category.color) RelativeTimeText(date: todo.updatedAt) } diff --git a/DevLog/UI/Home/TodoDetailView.swift b/DevLog/UI/Home/TodoDetailView.swift index 31f914f7..709eed2a 100644 --- a/DevLog/UI/Home/TodoDetailView.swift +++ b/DevLog/UI/Home/TodoDetailView.swift @@ -118,7 +118,7 @@ private struct TodoDetailInfoSheetView: View { HStack { Text("카테고리") Spacer() - Text(TodoCategoryPreferenceItem(from: todo.category).localizedName) + Text(TodoCategoryItem(from: todo.category).localizedName) .foregroundStyle(.secondary) } diff --git a/DevLog/UI/Home/TodoListView.swift b/DevLog/UI/Home/TodoListView.swift index 1a763c60..7601b366 100644 --- a/DevLog/UI/Home/TodoListView.swift +++ b/DevLog/UI/Home/TodoListView.swift @@ -41,7 +41,7 @@ struct TodoListView: View { set: { viewModel.send(.setIsSearching($0)) } ), placement: .navigationBarDrawer(displayMode: .always), - prompt: "\(TodoCategoryPreferenceItem(from: viewModel.state.category).localizedName) 검색" + prompt: "\(TodoCategoryItem(from: viewModel.state.category).localizedName) 검색" ) } } @@ -76,7 +76,7 @@ struct TodoListView: View { ) { Label(viewModel.state.toastMessage, systemImage: "arrow.uturn.left") } - .navigationTitle(TodoCategoryPreferenceItem(from: viewModel.state.category).localizedName) + .navigationTitle(TodoCategoryItem(from: viewModel.state.category).localizedName) .fullScreenCover(isPresented: Binding( get: { viewModel.state.showEditor }, set: { viewModel.send(.setShowEditor($0)) } @@ -222,7 +222,7 @@ struct TodoListView: View { set: { viewModel.send(.setIsSearching($0)) } ), placement: .navigationBarDrawer(displayMode: .always), - prompt: "\(TodoCategoryPreferenceItem(from: viewModel.state.category).localizedName) 검색" + prompt: "\(TodoCategoryItem(from: viewModel.state.category).localizedName) 검색" ) } @@ -235,7 +235,7 @@ struct TodoListView: View { : Array(searchResults.prefix(limit)) if viewModel.state.searchText.isEmpty { - Text("\(TodoCategoryPreferenceItem(from: viewModel.state.category).localizedName)의 제목이나 내용을 검색해 보세요.") + Text("\(TodoCategoryItem(from: viewModel.state.category).localizedName)의 제목이나 내용을 검색해 보세요.") .foregroundStyle(Color.gray) .frame(maxWidth: .infinity) } else if viewModel.state.isLoading { diff --git a/DevLog/UI/Home/TodoManageView.swift b/DevLog/UI/Home/TodoManageView.swift index 7cfe1a10..99237bf3 100644 --- a/DevLog/UI/Home/TodoManageView.swift +++ b/DevLog/UI/Home/TodoManageView.swift @@ -10,7 +10,7 @@ import SwiftUI struct TodoManageView: View { @State var viewModel: TodoManageViewModel @State private var tmpText = "" - var onDismiss: (([TodoCategoryPreferenceItem]) -> Void)? + var onDismiss: (([TodoCategoryItem]) -> Void)? var body: some View { NavigationStack { diff --git a/DevLog/UI/Profile/ProfileView.swift b/DevLog/UI/Profile/ProfileView.swift index a077bb93..a7872ac6 100644 --- a/DevLog/UI/Profile/ProfileView.swift +++ b/DevLog/UI/Profile/ProfileView.swift @@ -332,7 +332,7 @@ struct ProfileView: View { Button { router.push(Path.activity(activity)) } label: { - let todoCategoryItem = TodoCategoryPreferenceItem(from: activity.todo.category) + let todoCategoryItem = TodoCategoryItem(from: activity.todo.category) HStack(spacing: 8) { Image(systemName: todoCategoryItem.symbolName) .foregroundStyle(todoCategoryItem.color) diff --git a/DevLog/UI/PushNotification/PushNotificationListView.swift b/DevLog/UI/PushNotification/PushNotificationListView.swift index 84036dbe..d83ed018 100644 --- a/DevLog/UI/PushNotification/PushNotificationListView.swift +++ b/DevLog/UI/PushNotification/PushNotificationListView.swift @@ -246,7 +246,7 @@ struct PushNotificationListView: View { private func notificationRow(_ item: PushNotificationItem) -> some View { HStack { VStack { - let todoCategoryItem = TodoCategoryPreferenceItem(from: item.todoCategory) + let todoCategoryItem = TodoCategoryItem(from: item.todoCategory) RoundedRectangle(cornerRadius: 8) .fill(todoCategoryItem.color) .frame(width: sceneWidth * 0.08, height: sceneWidth * 0.08) diff --git a/DevLog/UI/Today/TodayView.swift b/DevLog/UI/Today/TodayView.swift index 4c639252..1765db8b 100644 --- a/DevLog/UI/Today/TodayView.swift +++ b/DevLog/UI/Today/TodayView.swift @@ -289,7 +289,7 @@ private struct TodayTodoRow: View { let item: TodayTodoItem var body: some View { - let todoCategoryItem = TodoCategoryPreferenceItem(from: item.category) + let todoCategoryItem = TodoCategoryItem(from: item.category) VStack(alignment: .leading, spacing: 8) { HStack(spacing: 8) { Image(systemName: todoCategoryItem.symbolName)