Rewrite MealPlanSyncManager.performSync() (renamed from performInitialSync) to discover _mealPlanAssignment metadata from all server recipes, not just locally- known ones. On first sync all recipes are checked; on subsequent syncs only recipes modified since lastMealPlanSyncDate are fetched (max 5 concurrent). Trigger meal plan sync from pull-to-refresh on both the recipe and meal plan tabs, and from the "Refresh all" toolbar button. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
234 lines
8.9 KiB
Swift
234 lines
8.9 KiB
Swift
//
|
|
// GroceryStateSyncManager.swift
|
|
// Nextcloud Cookbook iOS Client
|
|
//
|
|
|
|
import Foundation
|
|
import OSLog
|
|
|
|
@MainActor
|
|
class GroceryStateSyncManager {
|
|
private weak var appState: AppState?
|
|
private weak var groceryManager: GroceryListManager?
|
|
|
|
private var debounceTimers: [String: Task<Void, Never>] = [:]
|
|
private let debounceInterval: TimeInterval = 2.0
|
|
private var isReconciling = false
|
|
|
|
private let dataStore = DataStore()
|
|
private let pendingRemovalPath = "grocery_pending_removals.data"
|
|
private(set) var pendingRemovalRecipeIds: Set<String> = []
|
|
|
|
init(appState: AppState, groceryManager: GroceryListManager) {
|
|
self.appState = appState
|
|
self.groceryManager = groceryManager
|
|
}
|
|
|
|
// MARK: - Push Flow
|
|
|
|
/// Debounced sync trigger. Waits `debounceInterval` seconds then pushes state for the recipe.
|
|
func scheduleSync(forRecipeId recipeId: String) {
|
|
guard UserSettings.shared.grocerySyncEnabled else { return }
|
|
guard !isReconciling else { return }
|
|
|
|
debounceTimers[recipeId]?.cancel()
|
|
debounceTimers[recipeId] = Task { [weak self] in
|
|
try? await Task.sleep(nanoseconds: UInt64(2_000_000_000))
|
|
guard !Task.isCancelled else { return }
|
|
await self?.pushGroceryState(forRecipeId: recipeId)
|
|
}
|
|
}
|
|
|
|
/// Builds local grocery state, fetches server recipe, merges, and PUTs back.
|
|
func pushGroceryState(forRecipeId recipeId: String) async {
|
|
guard let appState, let groceryManager else { return }
|
|
guard let recipeIdInt = Int(recipeId) else { return }
|
|
|
|
// Fetch latest recipe from server first so we can detect deletions
|
|
guard let serverRecipe = await appState.getRecipe(id: recipeIdInt, fetchMode: .onlyServer) else {
|
|
Logger.data.error("Grocery sync: failed to fetch recipe \(recipeId) from server")
|
|
return
|
|
}
|
|
|
|
let serverState = serverRecipe.groceryState
|
|
|
|
// Build local state, passing server state so deleted items can be marked .removed
|
|
let localState = buildLocalState(forRecipeId: recipeId, groceryManager: groceryManager, serverState: serverState)
|
|
|
|
// Merge local state with server state
|
|
let merged = mergeStates(local: localState, server: serverState)
|
|
|
|
// Upload merged state
|
|
var updatedRecipe = serverRecipe
|
|
updatedRecipe.groceryState = merged
|
|
let (_, alert) = await appState.uploadRecipe(recipeDetail: updatedRecipe, createNew: false)
|
|
if let alert {
|
|
Logger.data.error("Grocery sync: failed to push state for recipe \(recipeId): \(String(describing: alert))")
|
|
}
|
|
}
|
|
|
|
// MARK: - Pull Flow
|
|
|
|
/// Reconciles server grocery state with local grocery data. Called when a recipe is loaded.
|
|
func reconcileFromServer(serverState: GroceryState?, recipeId: String, recipeName: String) {
|
|
guard let groceryManager else { return }
|
|
guard let serverState, !serverState.items.isEmpty else { return }
|
|
|
|
isReconciling = true
|
|
defer { isReconciling = false }
|
|
|
|
let localItems = Set(
|
|
groceryManager.groceryDict[recipeId]?.items.map(\.name) ?? []
|
|
)
|
|
|
|
for (itemName, itemState) in serverState.items {
|
|
switch itemState.status {
|
|
case .added:
|
|
if !localItems.contains(itemName) {
|
|
groceryManager.addItem(itemName, toRecipe: recipeId, recipeName: recipeName)
|
|
}
|
|
case .removed:
|
|
if localItems.contains(itemName) {
|
|
groceryManager.deleteItem(itemName, fromRecipe: recipeId)
|
|
}
|
|
case .completed:
|
|
// Don't re-add completed items; leave local state as-is
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Initial Sync
|
|
|
|
/// Pushes any local-only items and reconciles server items on app launch.
|
|
func performSync() async {
|
|
guard let appState, let groceryManager else { return }
|
|
|
|
await loadPendingRemovals()
|
|
|
|
let recipeIds = Array(groceryManager.groceryDict.keys)
|
|
for recipeId in recipeIds {
|
|
guard let recipeIdInt = Int(recipeId) else { continue }
|
|
|
|
// Push local state to server
|
|
await pushGroceryState(forRecipeId: recipeId)
|
|
|
|
// Fetch back and reconcile
|
|
if let serverRecipe = await appState.getRecipe(id: recipeIdInt, fetchMode: .onlyServer) {
|
|
let recipeName = groceryManager.groceryDict[recipeId]?.name ?? serverRecipe.name
|
|
reconcileFromServer(
|
|
serverState: serverRecipe.groceryState,
|
|
recipeId: recipeId,
|
|
recipeName: recipeName
|
|
)
|
|
}
|
|
}
|
|
|
|
// Push deletion state for recipes whose items were fully removed
|
|
for recipeId in pendingRemovalRecipeIds {
|
|
guard !recipeIds.contains(recipeId) else {
|
|
// Recipe was re-added locally since removal was tracked; clear it
|
|
pendingRemovalRecipeIds.remove(recipeId)
|
|
continue
|
|
}
|
|
await pushGroceryState(forRecipeId: recipeId)
|
|
pendingRemovalRecipeIds.remove(recipeId)
|
|
}
|
|
savePendingRemovals()
|
|
}
|
|
|
|
// MARK: - Merge Logic
|
|
|
|
/// Merges local and server states using per-item last-writer-wins on `modifiedAt`.
|
|
private func mergeStates(local: GroceryState, server: GroceryState?) -> GroceryState {
|
|
guard let server else { return local }
|
|
|
|
var merged = local.items
|
|
for (itemName, serverItem) in server.items {
|
|
if let localItem = merged[itemName] {
|
|
// Both have the item — keep the one with the later modifiedAt
|
|
let localDate = GroceryStateDate.date(from: localItem.modifiedAt) ?? .distantPast
|
|
let serverDate = GroceryStateDate.date(from: serverItem.modifiedAt) ?? .distantPast
|
|
if serverDate > localDate {
|
|
merged[itemName] = serverItem
|
|
}
|
|
} else {
|
|
// Only server has this item — keep it
|
|
merged[itemName] = serverItem
|
|
}
|
|
}
|
|
|
|
// Garbage collection: remove items that are removed/completed and older than 30 days
|
|
let thirtyDaysAgo = Date().addingTimeInterval(-30 * 24 * 60 * 60)
|
|
merged = merged.filter { _, item in
|
|
if item.status == .added { return true }
|
|
guard let modDate = GroceryStateDate.date(from: item.modifiedAt) else { return true }
|
|
return modDate > thirtyDaysAgo
|
|
}
|
|
|
|
return GroceryState(
|
|
lastModified: GroceryStateDate.now(),
|
|
items: merged
|
|
)
|
|
}
|
|
|
|
// MARK: - Build Local State
|
|
|
|
/// Builds a `GroceryState` from the current local grocery data for a recipe.
|
|
/// When `serverState` is provided, any server item with `.added` status that is
|
|
/// absent locally is emitted as `.removed` so the deletion propagates to the server.
|
|
private func buildLocalState(forRecipeId recipeId: String, groceryManager: GroceryListManager, serverState: GroceryState?) -> GroceryState {
|
|
var items: [String: GroceryItemState] = [:]
|
|
let now = GroceryStateDate.now()
|
|
|
|
// Existing local items
|
|
if let groceryRecipe = groceryManager.groceryDict[recipeId] {
|
|
for item in groceryRecipe.items {
|
|
let status: GroceryItemState.Status = item.isChecked ? .completed : .added
|
|
items[item.name] = GroceryItemState(status: status, addedAt: now, modifiedAt: now)
|
|
}
|
|
}
|
|
|
|
// Mark items that exist on server as .added but are absent locally as .removed
|
|
if let serverState {
|
|
for (itemName, serverItem) in serverState.items {
|
|
if items[itemName] == nil && serverItem.status == .added {
|
|
items[itemName] = GroceryItemState(
|
|
status: .removed,
|
|
addedAt: serverItem.addedAt,
|
|
modifiedAt: now
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
return GroceryState(lastModified: now, items: items)
|
|
}
|
|
|
|
// MARK: - Pending Removal Tracking
|
|
|
|
/// Records a recipe ID whose grocery items were fully removed, so that
|
|
/// `performSync` can push the deletion even after the key disappears
|
|
/// from `groceryDict`.
|
|
func trackPendingRemoval(recipeId: String) {
|
|
pendingRemovalRecipeIds.insert(recipeId)
|
|
savePendingRemovals()
|
|
}
|
|
|
|
func clearPendingRemoval(recipeId: String) {
|
|
guard pendingRemovalRecipeIds.remove(recipeId) != nil else { return }
|
|
savePendingRemovals()
|
|
}
|
|
|
|
private func loadPendingRemovals() async {
|
|
guard let loaded: Set<String> = try? await dataStore.load(fromPath: pendingRemovalPath) else { return }
|
|
pendingRemovalRecipeIds = loaded
|
|
}
|
|
|
|
private func savePendingRemovals() {
|
|
Task {
|
|
await dataStore.save(data: pendingRemovalRecipeIds, toPath: pendingRemovalPath)
|
|
}
|
|
}
|
|
}
|