import Foundation import Observation @Observable @MainActor final class WorkspaceStore { var notebooks: [Notebook] = [] var memos: [MemoSummary] = [] var selectedNotebookId: String? var searchText = "" var sort: MemoSortMode = .updatedDesc var filter: MobileMemoFilterMode = .all var selectedTag: String? var totalCount = 0 var nextOffset: Int? var isLoadingList = false var isLoadingMore = false var listError: String? var selectionMode = false var selectedMemoIds: Set = [] var showNotebookPicker = false var startCreatingNotebook = false var showActions = false var showTagFilterPicker = false private var searchTask: Task? var activeNotebook: Notebook? { guard let selectedNotebookId else { return nil } return notebooks.first { $0.id == selectedNotebookId } } func reload(env: AppEnvironment, resetOffset: Bool = true) { guard let scope = env.session.dataScope else { notebooks = [] memos = [] return } do { notebooks = try env.mirror.listNotebooks(scope: scope) try loadMemos(env: env, scope: scope, resetOffset: resetOffset) listError = nil } catch { listError = error.localizedDescription } } func scheduleSearch(env: AppEnvironment) { searchTask?.cancel() searchTask = Task { try? await Task.sleep(nanoseconds: 250_000_000) guard !Task.isCancelled else { return } reload(env: env) } } func loadMemos(env: AppEnvironment, scope: String, resetOffset: Bool) throws { isLoadingList = resetOffset defer { isLoadingList = false } let offset = resetOffset ? 0 : (nextOffset ?? memos.count) let notebookIds: [String]? = { guard selectedTag == nil, let selectedNotebookId else { return nil } return NotebookHierarchy.scopeIds( notebooks: notebooks, targetNotebookId: selectedNotebookId, includeDescendants: env.preferences.showDescendantNotes ) }() let result = try env.mirror.listMemos( scope: scope, params: LocalMemoListParams( notebookId: nil, notebookIds: notebookIds, q: searchText, tag: selectedTag, sort: sort, filter: filter.toMemoFilterMode(), limit: 50, offset: offset ) ) if resetOffset { memos = result.memos } else { let existing = Set(memos.map(\.id)) memos.append(contentsOf: result.memos.filter { !existing.contains($0.id) }) } totalCount = result.totalCount nextOffset = result.nextOffset } func loadMore(env: AppEnvironment) { guard let scope = env.session.dataScope, nextOffset != nil, !isLoadingMore else { return } isLoadingMore = true defer { isLoadingMore = false } try? loadMemos(env: env, scope: scope, resetOffset: false) } func toggleFilter(_ requested: MobileMemoFilterMode) { selectedTag = nil filter = MobileUI.toggleMemoFilterMode(current: filter, requested: requested) } func selectTag(_ tag: String?) { selectedTag = tag selectedNotebookId = nil filter = .all searchText = "" clearSelection() } func clearTagFilter() { selectedTag = nil filter = .all clearSelection() } func enterSelection(memoId: String? = nil) { selectionMode = true if let memoId { selectedMemoIds = [memoId] } } func clearSelection() { selectionMode = false selectedMemoIds = [] } func toggleSelected(_ memoId: String) { selectedMemoIds = MobileUI.toggleMemoSelection(current: selectedMemoIds, memoId: memoId) } var allVisibleMemosSelected: Bool { !memos.isEmpty && memos.allSatisfy { selectedMemoIds.contains($0.id) } } var nextSelectionPinValue: Bool { let selected = memos.filter { selectedMemoIds.contains($0.id) } return !selected.isEmpty && !selected.allSatisfy(\.isPinned) } func toggleVisibleSelection() { let visibleIds = Set(memos.map(\.id)) if allVisibleMemosSelected { selectedMemoIds.subtract(visibleIds) } else { selectionMode = true selectedMemoIds.formUnion(visibleIds) } } func softDelete(env: AppEnvironment, memoId: String) async { guard let scope = env.session.dataScope else { return } do { if memoId.hasPrefix("local:") { try env.outbox.cancelMemo(scope: scope, memoId: memoId) try env.mirror.deleteMemo(scope: scope, id: memoId) } else { _ = try env.mirror.softDeleteMemo(scope: scope, id: memoId) try await env.session.client.deleteMemo(id: memoId, permanent: false) } selectedMemoIds.remove(memoId) reload(env: env) } catch { listError = error.localizedDescription } } func softDeleteSelection(env: AppEnvironment) async { let ids = Array(selectedMemoIds) for id in ids { await softDelete(env: env, memoId: id) } clearSelection() } func togglePin(env: AppEnvironment, memo: MemoSummary) async { guard let scope = env.session.dataScope else { return } do { let updated = try await env.session.client.updateMemo( id: memo.id, expectedRevision: nil, expectedContentHash: nil, editSessionId: nil, notebookId: nil, title: nil, isPinned: !memo.isPinned, contentMarkdown: nil, tags: nil ) try env.mirror.upsertMemo(scope: scope, memo: updated) reload(env: env) } catch { listError = error.localizedDescription } } func pinSelection(env: AppEnvironment, isPinned: Bool) async { guard let scope = env.session.dataScope, !selectedMemoIds.isEmpty else { return } do { for memoId in selectedMemoIds { let updated = try await env.session.client.updateMemo( id: memoId, expectedRevision: nil, expectedContentHash: nil, editSessionId: nil, notebookId: nil, title: nil, isPinned: isPinned, contentMarkdown: nil, tags: nil ) try env.mirror.upsertMemo(scope: scope, memo: updated) } clearSelection() reload(env: env) } catch { listError = error.localizedDescription } } func moveSelection(env: AppEnvironment, notebookId: String) async { guard let scope = env.session.dataScope else { return } for memoId in selectedMemoIds { guard var memo = try? env.mirror.resolveMemo(scope: scope, id: memoId) else { continue } memo.notebookId = notebookId memo.updatedAt = EdgeEverDate.nowString() try? env.mirror.upsertMemo(scope: scope, memo: memo) try? env.outbox.enqueueUpdate( scope: scope, payload: MemoUpdatePayload( memoId: memo.id, expectedRevision: memo.revision, expectedContentHash: memo.contentHash, title: memo.title ?? "", contentMarkdown: memo.contentMarkdown, notebookId: notebookId, tags: memo.tags ) ) } await env.runSyncCycle() clearSelection() reload(env: env) } } extension MobileMemoFilterMode { func toMemoFilterMode() -> MemoFilterMode { switch self { case .all: return .all case .tagged: return .tagged case .untagged: return .untagged case .pinned: return .pinned } } }