diff --git a/packages/app/src/stores/quote-store.ts b/packages/app/src/stores/quote-store.ts index c40e51b7..cafb6565 100644 --- a/packages/app/src/stores/quote-store.ts +++ b/packages/app/src/stores/quote-store.ts @@ -1,19 +1,13 @@ import { ALL_QUOTE_CATEGORIES, assertPersisted, - type BulkImportResult, buildCustomQuote, - type CSVQuoteRow, DEFAULT_SETTINGS, - generateId, - getRandomQuote, logger, notifyDeleted, notifyMutated, - notifyMutatedBulk, type Quote, type QuoteCategory, - type QuoteCollection, STORAGE_KEYS, storageWriteErrorMessage, } from '@cuewise/shared'; @@ -21,265 +15,31 @@ import { getCollections, getCurrentQuote, getQuotes, - getQuotesRaw, getSettings, - setCurrentQuote, setQuotes, - setQuotesRaw, - updateCollections, updateQuotes, withCollectionLock, } from '@cuewise/storage'; import { create } from 'zustand'; import { SEED_QUOTES } from '../data/seed-quotes'; -import { useSettingsStore } from './settings-store'; +import { createBulkActions } from './quote-store/bulk-actions'; +import { createCollectionActions } from './quote-store/collection-actions'; +import { + CARD_NOT_REMEMBERED, + navigateHistory, + persistCurrentQuote, + persistFilterSettings, + persistOneQuote, + pickQuote, + refreshCardIfQuoteGone, + reportQuoteGone, + syncsLocalEdits, +} from './quote-store/helpers'; +import type { QuoteStore } from './quote-store/types'; import { createStaleLatch, createStorageObserver, sameEntities } from './storage-changes'; import { useToastStore } from './toast-store'; -/** Seed quotes not present in the given list, keyed by id. */ -function getMissingSeedQuotes(quotes: Quote[]): Quote[] { - const existingIds = new Set(quotes.map((q) => q.id)); - return SEED_QUOTES.filter((sq) => !existingIds.has(sq.id)); -} - -/** - * Step one entry through quote history. 'back' moves toward older quotes, 'forward' toward - * newer ones. Hidden/deleted quotes are skipped by recursing in the same direction. - */ -async function navigateHistory( - get: () => QuoteStore, - set: (partial: Partial) => void, - direction: 'back' | 'forward' -): Promise { - const isBack = direction === 'back'; - try { - const { quotes, quoteHistory, historyIndex } = get(); - - // Already at the end of history in this direction - if (isBack ? historyIndex >= quoteHistory.length - 1 : historyIndex <= 0) { - return; - } - - const newIndex = isBack ? historyIndex + 1 : historyIndex - 1; - const quoteId = quoteHistory[newIndex]; - const quote = quotes.find((q) => q.id === quoteId); - - if (quote && !quote.isHidden) { - if (!(await persistCurrentQuote(quote, 'navigateHistory'))) { - useToastStore.getState().warning(CARD_NOT_REMEMBERED, { collapseRepeats: true }); - } - set({ currentQuote: quote, historyIndex: newIndex }); - await get().incrementViewCount(quote.id); - } else { - // Quote was deleted or hidden, skip it - set({ historyIndex: newIndex }); - await navigateHistory(get, set, direction); - } - } catch (error) { - logger.error(`Error going ${direction} in history`, error); - // Not `error` state: both renderers read that as "the load failed" and replace the page. - // A single action that failed has its toast. - useToastStore.getState().error(`Failed to navigate ${direction}. Please try again.`); - } -} - -interface QuoteStore { - quotes: Quote[]; - currentQuote: Quote | null; - isLoading: boolean; - error: string | null; - quoteHistory: string[]; // Array of quote IDs in viewing order - historyIndex: number; // Current position in history (0 = most recent) - enabledCategories: QuoteCategory[]; // Categories to show (persisted to settings) - showCustomQuotes: boolean; // Show custom quotes in filter (persisted to settings) - showFavoritesOnly: boolean; // Show only favorite quotes (persisted to settings) - - // Collections state - collections: QuoteCollection[]; - activeCollectionIds: string[]; // Enabled collection filters (persisted to settings) - - // Actions - initialize: () => Promise; - /** `userInitiated` warns when the card write is lost; unprompted ticks stay quiet. */ - refreshQuote: (options?: { userInitiated?: boolean }) => Promise; - goBack: () => Promise; - goForward: () => Promise; - canGoBack: () => boolean; - canGoForward: () => boolean; - toggleFavorite: (quoteId: string) => Promise; - hideQuote: (quoteId: string) => Promise; - unhideQuote: (quoteId: string) => Promise; - /** False when the write did not persist, so the form can keep what the user typed. */ - addCustomQuote: ( - text: string, - author: string, - category: QuoteCategory, - source?: string, - notes?: string - ) => Promise; - /** 'gone' when a pull deleted it first, 'failed' when the write did not persist. */ - editQuote: ( - quoteId: string, - updates: { - text?: string; - author?: string; - category?: QuoteCategory; - source?: string; - notes?: string; - } - ) => Promise; - deleteQuote: (quoteId: string) => Promise; - incrementViewCount: (quoteId: string) => Promise; - setEnabledCategories: (categories: QuoteCategory[]) => Promise; - toggleCategory: (category: QuoteCategory) => Promise; - toggleCustomQuotes: () => Promise; - toggleFavoritesOnly: () => Promise; - - // Bulk operations - /** False when the write did not persist, so the caller can keep the user's selection. */ - bulkDelete: (quoteIds: string[]) => Promise; - bulkToggleFavorite: (quoteIds: string[], setFavorite: boolean) => Promise; - bulkToggleHidden: (quoteIds: string[], setHidden: boolean) => Promise; - - // Restoration operations - restoreMissingQuotes: () => Promise<{ restored: number }>; - resetAllQuotes: () => Promise; - getMissingSeedQuoteCount: () => number; - - // Collection operations - createCollection: (name: string, description?: string) => Promise; - /** 'gone' when a pull deleted it first — the store has already told the user, so do not retry. */ - updateCollection: ( - id: string, - updates: Partial> - ) => Promise; - deleteCollection: (id: string) => Promise; - /** 'gone' when a pull deleted the quote first, 'failed' when the write did not persist. */ - addQuoteToCollection: (quoteId: string, collectionId: string) => Promise; - removeQuoteFromCollection: (quoteId: string, collectionId: string) => Promise; - addQuotesToCollection: (quoteIds: string[], collectionId: string) => Promise; - toggleCollection: (collectionId: string) => Promise; - setActiveCollectionIds: (collectionIds: string[]) => Promise; - getQuotesInCollection: (collectionId: string) => Quote[]; - - // CSV Import - bulkAddQuotes: (quoteRows: CSVQuoteRow[], collectionId?: string) => Promise; -} - -/** - * Persists current filter settings. Goes through the settings store rather than storage directly: - * that path is serialized against other settings writes, checks the result, surfaces its own error - * toast, and notifies sync for each changed key (the quoteFilter* keys are not device-local). - */ -async function persistFilterSettings(state: QuoteStore): Promise { - await useSettingsStore.getState().updateSettings({ - quoteFilterEnabledCategories: state.enabledCategories, - quoteFilterShowCustomQuotes: state.showCustomQuotes, - quoteFilterShowFavoritesOnly: state.showFavoritesOnly, - quoteFilterActiveCollectionIds: state.activeCollectionIds, - }); -} - -/** - * A write aimed at one quote. Both members carry a list storage has vouched for: `gone` never - * wrote, and a write that fails throws instead of answering. Announcing a gone id would mark it - * dirty, which pushes as a tombstone this device never made. - */ -type OneQuoteWrite = - | { kind: 'gone'; quotes: Quote[] } - | { kind: 'saved'; quotes: Quote[]; target: Quote }; - -async function persistOneQuote( - quoteId: string, - change: (quote: Quote) => Quote -): Promise { - // Its own lock rather than updateQuotes, so a gone id skips the write entirely: rewriting an - // identical list can still fail, and the caller would report that instead of the gone. - return withCollectionLock('quotes', async (): Promise => { - const current = await getQuotes(); - // Boxed because TypeScript does not track an assignment inside the callback: a bare `let` - // narrows to `never` past the guard, and the compiler stops checking `target` altogether. - const hit: { target: Quote | null } = { target: null }; - const quotes = current.map((q) => { - if (q.id !== quoteId) { - return q; - } - hit.target = change(q); - return hit.target; - }); - if (hit.target === null) { - return { kind: 'gone', quotes: current }; - } - assertPersisted(await setQuotes(quotes)); - return { kind: 'saved', quotes, target: hit.target }; - }); -} - -/** - * Seed quotes do reach the account — the enroll backfill claims every stored id — but only a - * custom quote's edit is announced from here, so a seed edit stays on this device. - */ -function syncsLocalEdits(quote: Quote | null): boolean { - return quote?.isCustom === true; -} - -/** - * The card's own key. `initialize` reads back only its id and re-resolves it against the list, - * so losing this write matters only where the id changes AND the stored one still resolves: a - * writer that keeps the id, or one replacing an id the list no longer holds, loses nothing. - * Answers rather than throwing: the list write has usually already landed, and `getStorageArea` - * rejects, so both exits have to end up in the same place. - */ -async function persistCurrentQuote(quote: Quote, action: string): Promise { - try { - const result = await setCurrentQuote(quote); - if (result?.success !== true) { - logger.error(`${action}: could not persist the displayed quote`, result?.error, { - quoteId: quote.id, - }); - return false; - } - return true; - } catch (error) { - logger.error(`${action}: could not persist the displayed quote`, error, { quoteId: quote.id }); - return false; - } -} - -/** A write aimed at one entity: 'gone' is not a failure, and a retry cannot fix it. */ -export type QuoteWriteOutcome = 'saved' | 'gone' | 'failed'; - -/** - * The card renders `currentQuote`, which no observer converges, so a writer that finds the quote - * gone has to move the card itself. - * - * Checked against `written` rather than trusted from the call site: a caller that reaches here - * with the quote still present would otherwise reroll the card for no reason. - */ -async function refreshCardIfQuoteGone( - quoteId: string, - written: Quote[], - get: () => QuoteStore -): Promise { - const isGone = !written.some((q) => q.id === quoteId); - if (isGone && get().currentQuote?.id === quoteId) { - await get().refreshQuote(); - } -} - -/** The locked read no longer holds it: a pull deleted it between the user's click and the write. */ -function reportQuoteGone(action: string, quoteId: string): void { - // error, not warn: the shipped logLevel is 'error', and this is where a "but it's right - // there" report starts. - logger.error(`${action}: quote ${quoteId} was gone before the write`); - // Collapsed: AddQuotesToCollectionModal applies its pending changes one quote at a time, so a - // pull that removed the selection would otherwise stack one identical toast per quote. - useToastStore.getState().warning('This quote no longer exists', { collapseRepeats: true }); -} - -/** Only `initialize` reads the card key, so a lost write shows up on the next tab, not this one. */ -const CARD_NOT_REMEMBERED = - "Couldn't remember this quote — the next tab you open may still show the previous one."; +export type { QuoteWriteOutcome } from './quote-store/types'; const STALE_QUOTES_MESSAGE = "Cuewise couldn't re-read your quotes just now, so what you see may be out of date."; @@ -307,26 +67,7 @@ const quotesObserver = createStorageObserver( createStaleLatch((message) => useToastStore.getState().warning(message), STALE_QUOTES_MESSAGE) ); -/** The four fields the pick filters on, named so the two adjacent booleans cannot be transposed. */ -interface QuoteFilters { - enabledCategories: QuoteCategory[]; - showCustomQuotes: boolean; - showFavoritesOnly: boolean; - activeCollectionIds: string[]; -} - -function pickQuote(quotes: Quote[], filters: QuoteFilters, excludeId?: string): Quote | null { - return getRandomQuote( - quotes, - excludeId, - filters.enabledCategories, - filters.showCustomQuotes, - filters.showFavoritesOnly, - filters.activeCollectionIds - ); -} - -export const useQuoteStore = create((set, get) => ({ +export const useQuoteStore = create((set, get, api) => ({ quotes: [], currentQuote: null, isLoading: true, @@ -745,675 +486,6 @@ export const useQuoteStore = create((set, get) => ({ await persistFilterSettings(get()); }, - // Bulk operations - bulkDelete: async (quoteIds: string[]) => { - try { - const { currentQuote } = get(); - const quoteIdSet = new Set(quoteIds); - // Collected from the locked read: a pull may have removed some of these, and a tombstone - // for one this write never deleted is authorship we do not have. - const deletedCustomIds: string[] = []; - // Not deletedCustomIds.length: seed quotes are deletable too, they are just not announced. - let matched = 0; - - const { result, quotes: updatedQuotes } = await updateQuotes((current) => { - for (const q of current) { - if (!quoteIdSet.has(q.id)) { - continue; - } - matched += 1; - if (syncsLocalEdits(q)) { - deletedCustomIds.push(q.id); - } - } - return current.filter((q) => !quoteIdSet.has(q.id)); - }); - assertPersisted(result); - set({ quotes: updatedQuotes, error: null }); - for (const id of deletedCustomIds) { - notifyDeleted('quotes', id); - } - - // If current quote was deleted, refresh to a new one - if (currentQuote && quoteIdSet.has(currentQuote.id)) { - await get().refreshQuote(); - } - - if (matched === 0) { - useToastStore.getState().warning('Those quotes no longer exist'); - return true; - } - useToastStore.getState().success(`Deleted ${matched} quotes`); - return true; - } catch (error) { - logger.error('Error bulk deleting quotes', error, { quoteIds, count: quoteIds.length }); - useToastStore - .getState() - .error(storageWriteErrorMessage(error, 'Failed to delete quotes. Please try again.')); - return false; - } - }, - - bulkToggleFavorite: async (quoteIds: string[], setFavorite: boolean) => { - try { - const { currentQuote } = get(); - const quoteIdSet = new Set(quoteIds); - const affectedCustomIds: string[] = []; - let matched = 0; - - const { result, quotes: updatedQuotes } = await updateQuotes((current) => { - for (const q of current) { - if (!quoteIdSet.has(q.id)) { - continue; - } - matched += 1; - if (syncsLocalEdits(q)) { - affectedCustomIds.push(q.id); - } - } - return current.map((q) => (quoteIdSet.has(q.id) ? { ...q, isFavorite: setFavorite } : q)); - }); - assertPersisted(result); - set({ quotes: updatedQuotes, error: null }); - notifyMutatedBulk('quotes', affectedCustomIds); - - // The persisted value: a pull may have changed this quote while the write held the lock. - if (currentQuote && quoteIdSet.has(currentQuote.id)) { - const persisted = updatedQuotes.find((q) => q.id === currentQuote.id); - if (persisted) { - await persistCurrentQuote(persisted, 'bulkToggleFavorite'); - set({ currentQuote: persisted }); - } else { - await refreshCardIfQuoteGone(currentQuote.id, updatedQuotes, get); - } - } - - if (matched === 0) { - useToastStore.getState().warning('Those quotes no longer exist'); - return true; - } - const action = setFavorite ? 'added to favorites' : 'removed from favorites'; - useToastStore.getState().success(`${matched} quotes ${action}`); - return true; - } catch (error) { - logger.error('Error bulk toggling favorites', error, { - quoteIds, - setFavorite, - count: quoteIds.length, - }); - useToastStore - .getState() - .error(storageWriteErrorMessage(error, 'Failed to update favorites. Please try again.')); - return false; - } - }, - - bulkToggleHidden: async (quoteIds: string[], setHidden: boolean) => { - try { - const { currentQuote } = get(); - const quoteIdSet = new Set(quoteIds); - const affectedCustomIds: string[] = []; - let matched = 0; - - const { result, quotes: updatedQuotes } = await updateQuotes((current) => { - for (const q of current) { - if (!quoteIdSet.has(q.id)) { - continue; - } - matched += 1; - if (syncsLocalEdits(q)) { - affectedCustomIds.push(q.id); - } - } - return current.map((q) => (quoteIdSet.has(q.id) ? { ...q, isHidden: setHidden } : q)); - }); - assertPersisted(result); - set({ quotes: updatedQuotes, error: null }); - notifyMutatedBulk('quotes', affectedCustomIds); - - if (currentQuote && quoteIdSet.has(currentQuote.id)) { - const persisted = updatedQuotes.find((q) => q.id === currentQuote.id); - if (!persisted) { - await refreshCardIfQuoteGone(currentQuote.id, updatedQuotes, get); - } else if (setHidden) { - await get().refreshQuote(); - } else { - await persistCurrentQuote(persisted, 'bulkToggleHidden'); - set({ currentQuote: persisted }); - } - } - - if (matched === 0) { - useToastStore.getState().warning('Those quotes no longer exist'); - return true; - } - const action = setHidden ? 'hidden' : 'unhidden'; - useToastStore.getState().success(`${matched} quotes ${action}`); - return true; - } catch (error) { - logger.error('Error bulk toggling hidden', error, { - quoteIds, - setHidden, - count: quoteIds.length, - }); - useToastStore - .getState() - .error(storageWriteErrorMessage(error, 'Failed to update quotes. Please try again.')); - return false; - } - }, - - // Restoration operations - restoreMissingQuotes: async () => { - try { - if (getMissingSeedQuotes(get().quotes).length === 0) { - useToastStore.getState().info('All default quotes are already present'); - return { restored: 0 }; - } - - // Recomputed against the locked read: a pull may have restored some of them already, and - // adding those again would duplicate ids. - let restored = 0; - const { result, quotes: updatedQuotes } = await updateQuotes((current) => { - const missing = getMissingSeedQuotes(current); - restored = missing.length; - return [...current, ...missing]; - }); - assertPersisted(result); - set({ quotes: updatedQuotes, error: null }); - - if (restored === 0) { - useToastStore.getState().info('All default quotes are already present'); - return { restored: 0 }; - } - useToastStore.getState().success(`Restored ${restored} missing quotes`); - return { restored }; - } catch (error) { - logger.error('Error restoring missing quotes', error); - const errorMessage = 'Failed to restore quotes. Please try again.'; - useToastStore.getState().error(storageWriteErrorMessage(error, errorMessage)); - throw error; - } - }, - - resetAllQuotes: async () => { - try { - // Create fresh copy of seed quotes with default properties - let cardForgotten = false; - const freshQuotes = SEED_QUOTES.map((q) => ({ - ...q, - isFavorite: false, - isHidden: false, - viewCount: 0, - lastViewed: undefined, - })); - - // Raw: a reset means every stored quote, including one this build cannot parse, and the - // preserving setter would carry those through. - const wiped: string[] = []; - let unusable = 0; - const tombstones = { complete: true }; - const resetResult = await withCollectionLock('quotes', async () => { - // Raw, to match the raw write: the validating read drops rows this build cannot parse, - // and the write destroys them anyway — their ids need tombstones too. Failing to read - // must not block the reset, which is the escape hatch for unreadable storage. - try { - for (const quote of await getQuotesRaw()) { - // The raw read validates nothing, and an id the server rejects wedges every later - // push for this device, not just this record. - if (typeof quote?.id !== 'string' || quote.id === '') { - unusable += 1; - tombstones.complete = false; - continue; - } - if (syncsLocalEdits(quote)) { - wiped.push(quote.id); - } - } - } catch (error) { - tombstones.complete = false; - logger.error('Resetting quotes without tombstones: could not read what is stored', error); - } - return setQuotesRaw(freshQuotes); - }); - assertPersisted(resetResult); - // error, not warn: the shipped logLevel is 'error', and rows destroyed with no tombstone - // possible are the kind of thing that has to survive to a report. After the write, so it - // cannot claim a destruction a failed reset never performed. - if (unusable > 0) { - logger.error(`Reset destroyed ${unusable} stored rows with no usable quote id`); - } - set({ quotes: freshQuotes, error: null }); - // Without these the reset is local only: every peer still holding these pushes them back. - for (const id of wiped) { - notifyDeleted('quotes', id); - } - - const newCurrent = pickQuote(freshQuotes, get()); - if (newCurrent === null) { - set({ currentQuote: null, quoteHistory: [], historyIndex: 0 }); - } else { - cardForgotten = !(await persistCurrentQuote(newCurrent, 'resetAllQuotes')); - set({ - currentQuote: newCurrent, - quoteHistory: [newCurrent.id], - historyIndex: 0, - }); - } - - if (cardForgotten) { - useToastStore.getState().warning(CARD_NOT_REMEMBERED, { collapseRepeats: true }); - } - if (tombstones.complete) { - useToastStore.getState().success('All quotes reset to defaults'); - } else { - useToastStore - .getState() - .warning( - "Quotes reset, but Cuewise couldn't read what it replaced. If you use sync, some may return from your other devices." - ); - } - } catch (error) { - logger.error('Error resetting quotes', error); - const errorMessage = 'Failed to reset quotes. Please try again.'; - useToastStore.getState().error(storageWriteErrorMessage(error, errorMessage)); - throw error; - } - }, - - getMissingSeedQuoteCount: () => { - return getMissingSeedQuotes(get().quotes).length; - }, - - // Collection operations - createCollection: async (name: string, description?: string) => { - try { - const now = new Date().toISOString(); - - const newCollection: QuoteCollection = { - id: generateId(), - name: name.trim(), - description: description?.trim(), - createdAt: now, - }; - - const { result, collections: updatedCollections } = await updateCollections((current) => [ - ...current, - newCollection, - ]); - // setCollections resolves {success:false} on quota rather than throwing, so the catch below - // never sees it and the collection would look created until the next read. - if (!result.success) { - logger.error('Failed to persist the new collection', result.error); - const errorMessage = 'Failed to create collection. Please try again.'; - useToastStore.getState().error(storageWriteErrorMessage(result.error, errorMessage)); - return false; - } - set({ collections: updatedCollections, error: null }); - notifyMutated('collections', newCollection.id); - - useToastStore.getState().success(`Collection "${name}" created`); - return true; - } catch (error) { - logger.error('Error creating collection', error); - const errorMessage = 'Failed to create collection. Please try again.'; - useToastStore.getState().error(storageWriteErrorMessage(error, errorMessage)); - return false; - } - }, - - updateCollection: async (id: string, updates) => { - try { - const now = new Date().toISOString(); - const applied = { found: false }; - - const { result, collections: updatedCollections } = await updateCollections((current) => - current.map((c) => { - if (c.id !== id) { - return c; - } - applied.found = true; - return { ...c, ...updates, updatedAt: now }; - }) - ); - if (!result.success) { - logger.error('Failed to persist the collection update', result.error); - const errorMessage = 'Failed to update collection. Please try again.'; - useToastStore.getState().error(storageWriteErrorMessage(result.error, errorMessage)); - return 'failed'; - } - // Announcing a write that found nothing marks a gone id dirty, and the next push seals a - // tombstone this device never authored. - if (!applied.found) { - logger.error(`updateCollection: collection ${id} was gone before the write`); - useToastStore.getState().warning('This collection no longer exists'); - return 'gone'; - } - set({ collections: updatedCollections, error: null }); - notifyMutated('collections', id); - - useToastStore.getState().success('Collection updated'); - return 'saved'; - } catch (error) { - logger.error('Error updating collection', error); - const errorMessage = 'Failed to update collection. Please try again.'; - useToastStore.getState().error(storageWriteErrorMessage(error, errorMessage)); - return 'failed'; - } - }, - - deleteCollection: async (id: string) => { - try { - // Remove collection - const { result, collections: updatedCollections } = await updateCollections((current) => - current.filter((c) => c.id !== id) - ); - if (!result.success) { - logger.error('Failed to persist the collection deletion', result.error); - const errorMessage = 'Failed to delete collection. Please try again.'; - useToastStore.getState().error(storageWriteErrorMessage(result.error, errorMessage)); - return false; - } - // Straight after the write, with nothing fallible in between: the steps below can throw, and - // a tombstone that never pushes lets a peer still holding the collection resurrect it. - notifyDeleted('collections', id); - - // Remove collection ID from all quotes that had it. Locked and re-read for its own sake: - // losing this leaves quotes pointing at a collection that no longer exists. - const unlink = await withCollectionLock<{ quotes: Quote[] | null; unlinked: boolean }>( - 'quotes', - async () => { - // Caught here, not by the outer catch: the collection is already deleted and tombstoned - // by this point, so an unreadable quote read is not a failed delete. - let current: Quote[]; - try { - current = await getQuotes(); - } catch (error) { - logger.error('Deleted the collection but could not read its quotes to unlink', error); - return { quotes: null, unlinked: false }; - } - const unlinkedCustomIds: string[] = []; - const next = current.map((q) => { - if (q.collectionIds?.includes(id)) { - if (syncsLocalEdits(q)) { - unlinkedCustomIds.push(q.id); - } - return { ...q, collectionIds: q.collectionIds.filter((cId) => cId !== id) }; - } - return q; - }); - const quotesResult = await setQuotes(next); - if (!quotesResult.success) { - logger.error( - 'Deleted the collection but could not unlink its quotes', - quotesResult.error - ); - return { quotes: current, unlinked: false }; - } - // Membership lives on the quote, so an unannounced unlink is undone by the next pull. - notifyMutatedBulk('quotes', unlinkedCustomIds); - return { quotes: next, unlinked: true }; - } - ); - - // Read at use time: the user can toggle a collection filter while this waits on the locks, - // and nothing converges this field afterwards. - const newActiveIds = get().activeCollectionIds.filter((cId) => cId !== id); - - set({ - collections: updatedCollections, - // Omitted when the read failed: there is no fresher list to commit than what is already here. - ...(unlink.quotes !== null && { quotes: unlink.quotes }), - activeCollectionIds: newActiveIds, - error: null, - }); - - // Persist updated filter settings (collection removed from active filters) - await persistFilterSettings(get()); - - if (!unlink.unlinked) { - useToastStore.getState().warning('Collection deleted, but its quotes still reference it'); - return true; - } - useToastStore.getState().success('Collection deleted'); - return true; - } catch (error) { - logger.error('Error deleting collection', error); - const errorMessage = 'Failed to delete collection. Please try again.'; - useToastStore.getState().error(storageWriteErrorMessage(error, errorMessage)); - return false; - } - }, - - addQuoteToCollection: async (quoteId: string, collectionId: string) => { - try { - const { currentQuote } = get(); - // Decided against the locked read: a pull may have added this membership already, and - // announcing a write that changed nothing marks the quote dirty for no reason. - const changed = { added: false }; - - const write = await persistOneQuote(quoteId, (q) => { - const currentIds = q.collectionIds ?? []; - if (currentIds.includes(collectionId)) { - return q; - } - changed.added = true; - return { ...q, collectionIds: [...currentIds, collectionId] }; - }); - if (write.kind === 'gone') { - set({ quotes: write.quotes, error: null }); - reportQuoteGone('addQuoteToCollection', quoteId); - await refreshCardIfQuoteGone(quoteId, write.quotes, get); - return 'gone'; - } - const { quotes: updatedQuotes, target } = write; - set({ quotes: updatedQuotes, error: null }); - if (changed.added && syncsLocalEdits(target)) { - notifyMutated('quotes', quoteId); - } - - // Update current quote if it was the one modified - if (currentQuote && currentQuote.id === quoteId) { - const updatedCurrentQuote = updatedQuotes.find((q) => q.id === quoteId); - if (updatedCurrentQuote) { - await persistCurrentQuote(updatedCurrentQuote, 'addQuoteToCollection'); - set({ currentQuote: updatedCurrentQuote }); - } - } - - return 'saved'; - } catch (error) { - logger.error('Error adding quote to collection', error); - const errorMessage = 'Failed to add quote to collection. Please try again.'; - // Collapsed for the same reason as reportQuoteGone: the modal applies one quote at a time, - // and a full disk fails every one of them. - useToastStore - .getState() - .error(storageWriteErrorMessage(error, errorMessage), { collapseRepeats: true }); - return 'failed'; - } - }, - - removeQuoteFromCollection: async (quoteId: string, collectionId: string) => { - try { - const { currentQuote } = get(); - // Decided against the locked read, for the same reason as addQuoteToCollection. - const changed = { removed: false }; - - const write = await persistOneQuote(quoteId, (q) => { - if (!q.collectionIds?.includes(collectionId)) { - return q; - } - changed.removed = true; - return { ...q, collectionIds: q.collectionIds.filter((cId) => cId !== collectionId) }; - }); - if (write.kind === 'gone') { - set({ quotes: write.quotes, error: null }); - reportQuoteGone('removeQuoteFromCollection', quoteId); - await refreshCardIfQuoteGone(quoteId, write.quotes, get); - return 'gone'; - } - const { quotes: updatedQuotes, target } = write; - set({ quotes: updatedQuotes, error: null }); - if (changed.removed && syncsLocalEdits(target)) { - notifyMutated('quotes', quoteId); - } - - // Update current quote if it was the one modified - if (currentQuote && currentQuote.id === quoteId) { - const updatedCurrentQuote = updatedQuotes.find((q) => q.id === quoteId); - if (updatedCurrentQuote) { - await persistCurrentQuote(updatedCurrentQuote, 'removeQuoteFromCollection'); - set({ currentQuote: updatedCurrentQuote }); - } - } - - return 'saved'; - } catch (error) { - logger.error('Error removing quote from collection', error); - const errorMessage = 'Failed to remove quote from collection. Please try again.'; - useToastStore - .getState() - .error(storageWriteErrorMessage(error, errorMessage), { collapseRepeats: true }); - return 'failed'; - } - }, - - addQuotesToCollection: async (quoteIds: string[], collectionId: string) => { - try { - const { collections, currentQuote } = get(); - const quoteIdSet = new Set(quoteIds); - // Only the ones this write actually moved: membership lives on the quote itself. - const affectedCustomIds: string[] = []; - // Separately from `added`: zero of both is a pull deleting them, zero added alone is - // membership they already had. - let matched = 0; - let added = 0; - - const { result, quotes: updatedQuotes } = await updateQuotes((current) => - current.map((q) => { - if (!quoteIdSet.has(q.id)) { - return q; - } - matched += 1; - const currentIds = q.collectionIds ?? []; - if (currentIds.includes(collectionId)) { - return q; - } - added += 1; - if (syncsLocalEdits(q)) { - affectedCustomIds.push(q.id); - } - return { ...q, collectionIds: [...currentIds, collectionId] }; - }) - ); - assertPersisted(result); - set({ quotes: updatedQuotes, error: null }); - notifyMutatedBulk('quotes', affectedCustomIds); - - const collection = collections.find((c) => c.id === collectionId); - const collectionName = collection?.name ?? 'collection'; - if (currentQuote && quoteIdSet.has(currentQuote.id)) { - await refreshCardIfQuoteGone(currentQuote.id, updatedQuotes, get); - } - - if (matched === 0) { - // True, like bulkDelete: the write landed and matched nothing, which no retry fixes. - useToastStore.getState().warning('Those quotes no longer exist'); - return true; - } - if (added === 0) { - useToastStore.getState().info(`Those quotes are already in "${collectionName}"`); - return true; - } - useToastStore.getState().success(`${added} quotes added to "${collectionName}"`); - - return true; - } catch (error) { - logger.error('Error adding quotes to collection', error); - const errorMessage = 'Failed to add quotes to collection. Please try again.'; - useToastStore.getState().error(storageWriteErrorMessage(error, errorMessage)); - return false; - } - }, - - toggleCollection: async (collectionId: string) => { - const { activeCollectionIds } = get(); - if (activeCollectionIds.includes(collectionId)) { - set({ activeCollectionIds: activeCollectionIds.filter((id) => id !== collectionId) }); - } else { - set({ activeCollectionIds: [...activeCollectionIds, collectionId] }); - } - await persistFilterSettings(get()); - }, - - setActiveCollectionIds: async (collectionIds: string[]) => { - set({ activeCollectionIds: collectionIds }); - await persistFilterSettings(get()); - }, - - getQuotesInCollection: (collectionId: string) => { - const { quotes } = get(); - return quotes.filter((q) => q.collectionIds?.includes(collectionId)); - }, - - // CSV Import - Bulk add quotes with optional collection assignment - bulkAddQuotes: async (quoteRows: CSVQuoteRow[], collectionId?: string) => { - const result: BulkImportResult = { - success: false, - imported: 0, - failed: 0, - errors: [], - }; - - if (quoteRows.length === 0) { - result.errors.push('No quotes to import'); - return result; - } - - try { - const timestamp = Date.now(); - - // Create Quote objects from CSV rows - const newQuotes: Quote[] = quoteRows.map((row, index) => ({ - id: `custom-${timestamp}-${index}`, - text: row.text, - author: row.author, - category: row.category || 'inspiration', - isCustom: true, - isFavorite: false, - isHidden: false, - viewCount: 0, - source: row.source, - notes: row.notes, - collectionIds: collectionId ? [collectionId] : undefined, - })); - - // Add to existing quotes - const { result: writeResult, quotes: updatedQuotes } = await updateQuotes((current) => [ - ...current, - ...newQuotes, - ]); - assertPersisted(writeResult); - set({ quotes: updatedQuotes, error: null }); - notifyMutatedBulk( - 'quotes', - newQuotes.map((quote) => quote.id) - ); - - result.success = true; - result.imported = newQuotes.length; - - const collectionText = collectionId ? ' and added to collection' : ''; - useToastStore.getState().success(`Imported ${newQuotes.length} quotes${collectionText}`); - - return result; - } catch (error) { - logger.error('Error bulk adding quotes', error); - const errorMessage = 'Failed to import quotes. Please try again.'; - result.errors.push(errorMessage); - useToastStore.getState().error(storageWriteErrorMessage(error, errorMessage)); - return result; - } - }, + ...createBulkActions(set, get, api), + ...createCollectionActions(set, get, api), })); diff --git a/packages/app/src/stores/quote-store/bulk-actions.ts b/packages/app/src/stores/quote-store/bulk-actions.ts new file mode 100644 index 00000000..9317f58a --- /dev/null +++ b/packages/app/src/stores/quote-store/bulk-actions.ts @@ -0,0 +1,374 @@ +import { + assertPersisted, + type BulkImportResult, + type CSVQuoteRow, + logger, + notifyDeleted, + notifyMutatedBulk, + type Quote, + storageWriteErrorMessage, +} from '@cuewise/shared'; +import { getQuotesRaw, setQuotesRaw, updateQuotes, withCollectionLock } from '@cuewise/storage'; +import type { StateCreator } from 'zustand'; +import { SEED_QUOTES } from '../../data/seed-quotes'; +import { useToastStore } from '../toast-store'; +import { + CARD_NOT_REMEMBERED, + getMissingSeedQuotes, + persistCurrentQuote, + pickQuote, + refreshCardIfQuoteGone, + syncsLocalEdits, +} from './helpers'; +import type { QuoteStore } from './types'; + +export type BulkActions = Pick< + QuoteStore, + | 'bulkDelete' + | 'bulkToggleFavorite' + | 'bulkToggleHidden' + | 'bulkAddQuotes' + | 'restoreMissingQuotes' + | 'resetAllQuotes' + | 'getMissingSeedQuoteCount' +>; + +export const createBulkActions: StateCreator = (set, get) => ({ + bulkDelete: async (quoteIds: string[]) => { + try { + const { currentQuote } = get(); + const quoteIdSet = new Set(quoteIds); + // Collected from the locked read: a pull may have removed some of these, and a tombstone + // for one this write never deleted is authorship we do not have. + const deletedCustomIds: string[] = []; + // Not deletedCustomIds.length: seed quotes are deletable too, they are just not announced. + let matched = 0; + + const { result, quotes: updatedQuotes } = await updateQuotes((current) => { + for (const q of current) { + if (!quoteIdSet.has(q.id)) { + continue; + } + matched += 1; + if (syncsLocalEdits(q)) { + deletedCustomIds.push(q.id); + } + } + return current.filter((q) => !quoteIdSet.has(q.id)); + }); + assertPersisted(result); + set({ quotes: updatedQuotes, error: null }); + for (const id of deletedCustomIds) { + notifyDeleted('quotes', id); + } + + // If current quote was deleted, refresh to a new one + if (currentQuote && quoteIdSet.has(currentQuote.id)) { + await get().refreshQuote(); + } + + if (matched === 0) { + useToastStore.getState().warning('Those quotes no longer exist'); + return true; + } + useToastStore.getState().success(`Deleted ${matched} quotes`); + return true; + } catch (error) { + logger.error('Error bulk deleting quotes', error, { quoteIds, count: quoteIds.length }); + useToastStore + .getState() + .error(storageWriteErrorMessage(error, 'Failed to delete quotes. Please try again.')); + return false; + } + }, + + bulkToggleFavorite: async (quoteIds: string[], setFavorite: boolean) => { + try { + const { currentQuote } = get(); + const quoteIdSet = new Set(quoteIds); + const affectedCustomIds: string[] = []; + let matched = 0; + + const { result, quotes: updatedQuotes } = await updateQuotes((current) => { + for (const q of current) { + if (!quoteIdSet.has(q.id)) { + continue; + } + matched += 1; + if (syncsLocalEdits(q)) { + affectedCustomIds.push(q.id); + } + } + return current.map((q) => (quoteIdSet.has(q.id) ? { ...q, isFavorite: setFavorite } : q)); + }); + assertPersisted(result); + set({ quotes: updatedQuotes, error: null }); + notifyMutatedBulk('quotes', affectedCustomIds); + + // The persisted value: a pull may have changed this quote while the write held the lock. + if (currentQuote && quoteIdSet.has(currentQuote.id)) { + const persisted = updatedQuotes.find((q) => q.id === currentQuote.id); + if (persisted) { + await persistCurrentQuote(persisted, 'bulkToggleFavorite'); + set({ currentQuote: persisted }); + } else { + await refreshCardIfQuoteGone(currentQuote.id, updatedQuotes, get); + } + } + + if (matched === 0) { + useToastStore.getState().warning('Those quotes no longer exist'); + return true; + } + const action = setFavorite ? 'added to favorites' : 'removed from favorites'; + useToastStore.getState().success(`${matched} quotes ${action}`); + return true; + } catch (error) { + logger.error('Error bulk toggling favorites', error, { + quoteIds, + setFavorite, + count: quoteIds.length, + }); + useToastStore + .getState() + .error(storageWriteErrorMessage(error, 'Failed to update favorites. Please try again.')); + return false; + } + }, + + bulkToggleHidden: async (quoteIds: string[], setHidden: boolean) => { + try { + const { currentQuote } = get(); + const quoteIdSet = new Set(quoteIds); + const affectedCustomIds: string[] = []; + let matched = 0; + + const { result, quotes: updatedQuotes } = await updateQuotes((current) => { + for (const q of current) { + if (!quoteIdSet.has(q.id)) { + continue; + } + matched += 1; + if (syncsLocalEdits(q)) { + affectedCustomIds.push(q.id); + } + } + return current.map((q) => (quoteIdSet.has(q.id) ? { ...q, isHidden: setHidden } : q)); + }); + assertPersisted(result); + set({ quotes: updatedQuotes, error: null }); + notifyMutatedBulk('quotes', affectedCustomIds); + + if (currentQuote && quoteIdSet.has(currentQuote.id)) { + const persisted = updatedQuotes.find((q) => q.id === currentQuote.id); + if (!persisted) { + await refreshCardIfQuoteGone(currentQuote.id, updatedQuotes, get); + } else if (setHidden) { + await get().refreshQuote(); + } else { + await persistCurrentQuote(persisted, 'bulkToggleHidden'); + set({ currentQuote: persisted }); + } + } + + if (matched === 0) { + useToastStore.getState().warning('Those quotes no longer exist'); + return true; + } + const action = setHidden ? 'hidden' : 'unhidden'; + useToastStore.getState().success(`${matched} quotes ${action}`); + return true; + } catch (error) { + logger.error('Error bulk toggling hidden', error, { + quoteIds, + setHidden, + count: quoteIds.length, + }); + useToastStore + .getState() + .error(storageWriteErrorMessage(error, 'Failed to update quotes. Please try again.')); + return false; + } + }, + + restoreMissingQuotes: async () => { + try { + if (getMissingSeedQuotes(get().quotes).length === 0) { + useToastStore.getState().info('All default quotes are already present'); + return { restored: 0 }; + } + + // Recomputed against the locked read: a pull may have restored some of them already, and + // adding those again would duplicate ids. + let restored = 0; + const { result, quotes: updatedQuotes } = await updateQuotes((current) => { + const missing = getMissingSeedQuotes(current); + restored = missing.length; + return [...current, ...missing]; + }); + assertPersisted(result); + set({ quotes: updatedQuotes, error: null }); + + if (restored === 0) { + useToastStore.getState().info('All default quotes are already present'); + return { restored: 0 }; + } + useToastStore.getState().success(`Restored ${restored} missing quotes`); + return { restored }; + } catch (error) { + logger.error('Error restoring missing quotes', error); + const errorMessage = 'Failed to restore quotes. Please try again.'; + useToastStore.getState().error(storageWriteErrorMessage(error, errorMessage)); + throw error; + } + }, + + resetAllQuotes: async () => { + try { + // Create fresh copy of seed quotes with default properties + let cardForgotten = false; + const freshQuotes = SEED_QUOTES.map((q) => ({ + ...q, + isFavorite: false, + isHidden: false, + viewCount: 0, + lastViewed: undefined, + })); + + // Raw: a reset means every stored quote, including one this build cannot parse, and the + // preserving setter would carry those through. + const wiped: string[] = []; + let unusable = 0; + const tombstones = { complete: true }; + const resetResult = await withCollectionLock('quotes', async () => { + // Raw, to match the raw write: the validating read drops rows this build cannot parse, + // and the write destroys them anyway — their ids need tombstones too. Failing to read + // must not block the reset, which is the escape hatch for unreadable storage. + try { + for (const quote of await getQuotesRaw()) { + // The raw read validates nothing, and an id the server rejects wedges every later + // push for this device, not just this record. + if (typeof quote?.id !== 'string' || quote.id === '') { + unusable += 1; + tombstones.complete = false; + continue; + } + if (syncsLocalEdits(quote)) { + wiped.push(quote.id); + } + } + } catch (error) { + tombstones.complete = false; + logger.error('Resetting quotes without tombstones: could not read what is stored', error); + } + return setQuotesRaw(freshQuotes); + }); + assertPersisted(resetResult); + // error, not warn: the shipped logLevel is 'error', and rows destroyed with no tombstone + // possible are the kind of thing that has to survive to a report. After the write, so it + // cannot claim a destruction a failed reset never performed. + if (unusable > 0) { + logger.error(`Reset destroyed ${unusable} stored rows with no usable quote id`); + } + set({ quotes: freshQuotes, error: null }); + // Without these the reset is local only: every peer still holding these pushes them back. + for (const id of wiped) { + notifyDeleted('quotes', id); + } + + const newCurrent = pickQuote(freshQuotes, get()); + if (newCurrent === null) { + set({ currentQuote: null, quoteHistory: [], historyIndex: 0 }); + } else { + cardForgotten = !(await persistCurrentQuote(newCurrent, 'resetAllQuotes')); + set({ + currentQuote: newCurrent, + quoteHistory: [newCurrent.id], + historyIndex: 0, + }); + } + + if (cardForgotten) { + useToastStore.getState().warning(CARD_NOT_REMEMBERED, { collapseRepeats: true }); + } + if (tombstones.complete) { + useToastStore.getState().success('All quotes reset to defaults'); + } else { + useToastStore + .getState() + .warning( + "Quotes reset, but Cuewise couldn't read what it replaced. If you use sync, some may return from your other devices." + ); + } + } catch (error) { + logger.error('Error resetting quotes', error); + const errorMessage = 'Failed to reset quotes. Please try again.'; + useToastStore.getState().error(storageWriteErrorMessage(error, errorMessage)); + throw error; + } + }, + + getMissingSeedQuoteCount: () => { + return getMissingSeedQuotes(get().quotes).length; + }, + + // CSV Import - Bulk add quotes with optional collection assignment + bulkAddQuotes: async (quoteRows: CSVQuoteRow[], collectionId?: string) => { + const result: BulkImportResult = { + success: false, + imported: 0, + failed: 0, + errors: [], + }; + + if (quoteRows.length === 0) { + result.errors.push('No quotes to import'); + return result; + } + + try { + const timestamp = Date.now(); + + // Create Quote objects from CSV rows + const newQuotes: Quote[] = quoteRows.map((row, index) => ({ + id: `custom-${timestamp}-${index}`, + text: row.text, + author: row.author, + category: row.category || 'inspiration', + isCustom: true, + isFavorite: false, + isHidden: false, + viewCount: 0, + source: row.source, + notes: row.notes, + collectionIds: collectionId ? [collectionId] : undefined, + })); + + // Add to existing quotes + const { result: writeResult, quotes: updatedQuotes } = await updateQuotes((current) => [ + ...current, + ...newQuotes, + ]); + assertPersisted(writeResult); + set({ quotes: updatedQuotes, error: null }); + notifyMutatedBulk( + 'quotes', + newQuotes.map((quote) => quote.id) + ); + + result.success = true; + result.imported = newQuotes.length; + + const collectionText = collectionId ? ' and added to collection' : ''; + useToastStore.getState().success(`Imported ${newQuotes.length} quotes${collectionText}`); + + return result; + } catch (error) { + logger.error('Error bulk adding quotes', error); + const errorMessage = 'Failed to import quotes. Please try again.'; + result.errors.push(errorMessage); + useToastStore.getState().error(storageWriteErrorMessage(error, errorMessage)); + return result; + } + }, +}); diff --git a/packages/app/src/stores/quote-store/collection-actions.ts b/packages/app/src/stores/quote-store/collection-actions.ts new file mode 100644 index 00000000..eff9f86e --- /dev/null +++ b/packages/app/src/stores/quote-store/collection-actions.ts @@ -0,0 +1,378 @@ +import { + assertPersisted, + generateId, + logger, + notifyDeleted, + notifyMutated, + notifyMutatedBulk, + type Quote, + type QuoteCollection, + storageWriteErrorMessage, +} from '@cuewise/shared'; +import { + getQuotes, + setQuotes, + updateCollections, + updateQuotes, + withCollectionLock, +} from '@cuewise/storage'; +import type { StateCreator } from 'zustand'; +import { useToastStore } from '../toast-store'; +import { + persistCurrentQuote, + persistFilterSettings, + persistOneQuote, + refreshCardIfQuoteGone, + reportQuoteGone, + syncsLocalEdits, +} from './helpers'; +import type { QuoteStore } from './types'; + +export type CollectionActions = Pick< + QuoteStore, + | 'createCollection' + | 'updateCollection' + | 'deleteCollection' + | 'addQuoteToCollection' + | 'removeQuoteFromCollection' + | 'addQuotesToCollection' + | 'toggleCollection' + | 'setActiveCollectionIds' + | 'getQuotesInCollection' +>; + +export const createCollectionActions: StateCreator = ( + set, + get +) => ({ + createCollection: async (name: string, description?: string) => { + try { + const now = new Date().toISOString(); + + const newCollection: QuoteCollection = { + id: generateId(), + name: name.trim(), + description: description?.trim(), + createdAt: now, + }; + + const { result, collections: updatedCollections } = await updateCollections((current) => [ + ...current, + newCollection, + ]); + // setCollections resolves {success:false} on quota rather than throwing, so the catch below + // never sees it and the collection would look created until the next read. + if (!result.success) { + logger.error('Failed to persist the new collection', result.error); + const errorMessage = 'Failed to create collection. Please try again.'; + useToastStore.getState().error(storageWriteErrorMessage(result.error, errorMessage)); + return false; + } + set({ collections: updatedCollections, error: null }); + notifyMutated('collections', newCollection.id); + + useToastStore.getState().success(`Collection "${name}" created`); + return true; + } catch (error) { + logger.error('Error creating collection', error); + const errorMessage = 'Failed to create collection. Please try again.'; + useToastStore.getState().error(storageWriteErrorMessage(error, errorMessage)); + return false; + } + }, + + updateCollection: async (id: string, updates) => { + try { + const now = new Date().toISOString(); + const applied = { found: false }; + + const { result, collections: updatedCollections } = await updateCollections((current) => + current.map((c) => { + if (c.id !== id) { + return c; + } + applied.found = true; + return { ...c, ...updates, updatedAt: now }; + }) + ); + if (!result.success) { + logger.error('Failed to persist the collection update', result.error); + const errorMessage = 'Failed to update collection. Please try again.'; + useToastStore.getState().error(storageWriteErrorMessage(result.error, errorMessage)); + return 'failed'; + } + // Announcing a write that found nothing marks a gone id dirty, and the next push seals a + // tombstone this device never authored. + if (!applied.found) { + logger.error(`updateCollection: collection ${id} was gone before the write`); + useToastStore.getState().warning('This collection no longer exists'); + return 'gone'; + } + set({ collections: updatedCollections, error: null }); + notifyMutated('collections', id); + + useToastStore.getState().success('Collection updated'); + return 'saved'; + } catch (error) { + logger.error('Error updating collection', error); + const errorMessage = 'Failed to update collection. Please try again.'; + useToastStore.getState().error(storageWriteErrorMessage(error, errorMessage)); + return 'failed'; + } + }, + + deleteCollection: async (id: string) => { + try { + // Remove collection + const { result, collections: updatedCollections } = await updateCollections((current) => + current.filter((c) => c.id !== id) + ); + if (!result.success) { + logger.error('Failed to persist the collection deletion', result.error); + const errorMessage = 'Failed to delete collection. Please try again.'; + useToastStore.getState().error(storageWriteErrorMessage(result.error, errorMessage)); + return false; + } + // Straight after the write, with nothing fallible in between: the steps below can throw, and + // a tombstone that never pushes lets a peer still holding the collection resurrect it. + notifyDeleted('collections', id); + + // Remove collection ID from all quotes that had it. Locked and re-read for its own sake: + // losing this leaves quotes pointing at a collection that no longer exists. + const unlink = await withCollectionLock<{ quotes: Quote[] | null; unlinked: boolean }>( + 'quotes', + async () => { + // Caught here, not by the outer catch: the collection is already deleted and tombstoned + // by this point, so an unreadable quote read is not a failed delete. + let current: Quote[]; + try { + current = await getQuotes(); + } catch (error) { + logger.error('Deleted the collection but could not read its quotes to unlink', error); + return { quotes: null, unlinked: false }; + } + const unlinkedCustomIds: string[] = []; + const next = current.map((q) => { + if (q.collectionIds?.includes(id)) { + if (syncsLocalEdits(q)) { + unlinkedCustomIds.push(q.id); + } + return { ...q, collectionIds: q.collectionIds.filter((cId) => cId !== id) }; + } + return q; + }); + const quotesResult = await setQuotes(next); + if (!quotesResult.success) { + logger.error( + 'Deleted the collection but could not unlink its quotes', + quotesResult.error + ); + return { quotes: current, unlinked: false }; + } + // Membership lives on the quote, so an unannounced unlink is undone by the next pull. + notifyMutatedBulk('quotes', unlinkedCustomIds); + return { quotes: next, unlinked: true }; + } + ); + + // Read at use time: the user can toggle a collection filter while this waits on the locks, + // and nothing converges this field afterwards. + const newActiveIds = get().activeCollectionIds.filter((cId) => cId !== id); + + set({ + collections: updatedCollections, + // Omitted when the read failed: there is no fresher list to commit than what is already here. + ...(unlink.quotes !== null && { quotes: unlink.quotes }), + activeCollectionIds: newActiveIds, + error: null, + }); + + // Persist updated filter settings (collection removed from active filters) + await persistFilterSettings(get()); + + if (!unlink.unlinked) { + useToastStore.getState().warning('Collection deleted, but its quotes still reference it'); + return true; + } + useToastStore.getState().success('Collection deleted'); + return true; + } catch (error) { + logger.error('Error deleting collection', error); + const errorMessage = 'Failed to delete collection. Please try again.'; + useToastStore.getState().error(storageWriteErrorMessage(error, errorMessage)); + return false; + } + }, + + addQuoteToCollection: async (quoteId: string, collectionId: string) => { + try { + const { currentQuote } = get(); + // Decided against the locked read: a pull may have added this membership already, and + // announcing a write that changed nothing marks the quote dirty for no reason. + const changed = { added: false }; + + const write = await persistOneQuote(quoteId, (q) => { + const currentIds = q.collectionIds ?? []; + if (currentIds.includes(collectionId)) { + return q; + } + changed.added = true; + return { ...q, collectionIds: [...currentIds, collectionId] }; + }); + if (write.kind === 'gone') { + set({ quotes: write.quotes, error: null }); + reportQuoteGone('addQuoteToCollection', quoteId); + await refreshCardIfQuoteGone(quoteId, write.quotes, get); + return 'gone'; + } + const { quotes: updatedQuotes, target } = write; + set({ quotes: updatedQuotes, error: null }); + if (changed.added && syncsLocalEdits(target)) { + notifyMutated('quotes', quoteId); + } + + // Update current quote if it was the one modified + if (currentQuote && currentQuote.id === quoteId) { + const updatedCurrentQuote = updatedQuotes.find((q) => q.id === quoteId); + if (updatedCurrentQuote) { + await persistCurrentQuote(updatedCurrentQuote, 'addQuoteToCollection'); + set({ currentQuote: updatedCurrentQuote }); + } + } + + return 'saved'; + } catch (error) { + logger.error('Error adding quote to collection', error); + const errorMessage = 'Failed to add quote to collection. Please try again.'; + // Collapsed for the same reason as reportQuoteGone: the modal applies one quote at a time, + // and a full disk fails every one of them. + useToastStore + .getState() + .error(storageWriteErrorMessage(error, errorMessage), { collapseRepeats: true }); + return 'failed'; + } + }, + + removeQuoteFromCollection: async (quoteId: string, collectionId: string) => { + try { + const { currentQuote } = get(); + // Decided against the locked read, for the same reason as addQuoteToCollection. + const changed = { removed: false }; + + const write = await persistOneQuote(quoteId, (q) => { + if (!q.collectionIds?.includes(collectionId)) { + return q; + } + changed.removed = true; + return { ...q, collectionIds: q.collectionIds.filter((cId) => cId !== collectionId) }; + }); + if (write.kind === 'gone') { + set({ quotes: write.quotes, error: null }); + reportQuoteGone('removeQuoteFromCollection', quoteId); + await refreshCardIfQuoteGone(quoteId, write.quotes, get); + return 'gone'; + } + const { quotes: updatedQuotes, target } = write; + set({ quotes: updatedQuotes, error: null }); + if (changed.removed && syncsLocalEdits(target)) { + notifyMutated('quotes', quoteId); + } + + // Update current quote if it was the one modified + if (currentQuote && currentQuote.id === quoteId) { + const updatedCurrentQuote = updatedQuotes.find((q) => q.id === quoteId); + if (updatedCurrentQuote) { + await persistCurrentQuote(updatedCurrentQuote, 'removeQuoteFromCollection'); + set({ currentQuote: updatedCurrentQuote }); + } + } + + return 'saved'; + } catch (error) { + logger.error('Error removing quote from collection', error); + const errorMessage = 'Failed to remove quote from collection. Please try again.'; + useToastStore + .getState() + .error(storageWriteErrorMessage(error, errorMessage), { collapseRepeats: true }); + return 'failed'; + } + }, + + addQuotesToCollection: async (quoteIds: string[], collectionId: string) => { + try { + const { collections, currentQuote } = get(); + const quoteIdSet = new Set(quoteIds); + // Only the ones this write actually moved: membership lives on the quote itself. + const affectedCustomIds: string[] = []; + // Separately from `added`: zero of both is a pull deleting them, zero added alone is + // membership they already had. + let matched = 0; + let added = 0; + + const { result, quotes: updatedQuotes } = await updateQuotes((current) => + current.map((q) => { + if (!quoteIdSet.has(q.id)) { + return q; + } + matched += 1; + const currentIds = q.collectionIds ?? []; + if (currentIds.includes(collectionId)) { + return q; + } + added += 1; + if (syncsLocalEdits(q)) { + affectedCustomIds.push(q.id); + } + return { ...q, collectionIds: [...currentIds, collectionId] }; + }) + ); + assertPersisted(result); + set({ quotes: updatedQuotes, error: null }); + notifyMutatedBulk('quotes', affectedCustomIds); + + const collection = collections.find((c) => c.id === collectionId); + const collectionName = collection?.name ?? 'collection'; + if (currentQuote && quoteIdSet.has(currentQuote.id)) { + await refreshCardIfQuoteGone(currentQuote.id, updatedQuotes, get); + } + + if (matched === 0) { + // True, like bulkDelete: the write landed and matched nothing, which no retry fixes. + useToastStore.getState().warning('Those quotes no longer exist'); + return true; + } + if (added === 0) { + useToastStore.getState().info(`Those quotes are already in "${collectionName}"`); + return true; + } + useToastStore.getState().success(`${added} quotes added to "${collectionName}"`); + + return true; + } catch (error) { + logger.error('Error adding quotes to collection', error); + const errorMessage = 'Failed to add quotes to collection. Please try again.'; + useToastStore.getState().error(storageWriteErrorMessage(error, errorMessage)); + return false; + } + }, + + toggleCollection: async (collectionId: string) => { + const { activeCollectionIds } = get(); + if (activeCollectionIds.includes(collectionId)) { + set({ activeCollectionIds: activeCollectionIds.filter((id) => id !== collectionId) }); + } else { + set({ activeCollectionIds: [...activeCollectionIds, collectionId] }); + } + await persistFilterSettings(get()); + }, + + setActiveCollectionIds: async (collectionIds: string[]) => { + set({ activeCollectionIds: collectionIds }); + await persistFilterSettings(get()); + }, + + getQuotesInCollection: (collectionId: string) => { + const { quotes } = get(); + return quotes.filter((q) => q.collectionIds?.includes(collectionId)); + }, +}); diff --git a/packages/app/src/stores/quote-store/helpers.ts b/packages/app/src/stores/quote-store/helpers.ts new file mode 100644 index 00000000..7473c8d1 --- /dev/null +++ b/packages/app/src/stores/quote-store/helpers.ts @@ -0,0 +1,194 @@ +import { + assertPersisted, + getRandomQuote, + logger, + type Quote, + type QuoteCategory, +} from '@cuewise/shared'; +import { getQuotes, setCurrentQuote, setQuotes, withCollectionLock } from '@cuewise/storage'; +import { SEED_QUOTES } from '../../data/seed-quotes'; +import { useSettingsStore } from '../settings-store'; +import { useToastStore } from '../toast-store'; +import type { QuoteStore } from './types'; + +/** Seed quotes not present in the given list, keyed by id. */ +export function getMissingSeedQuotes(quotes: Quote[]): Quote[] { + const existingIds = new Set(quotes.map((q) => q.id)); + return SEED_QUOTES.filter((sq) => !existingIds.has(sq.id)); +} + +/** + * Step one entry through quote history. 'back' moves toward older quotes, 'forward' toward + * newer ones. Hidden/deleted quotes are skipped by recursing in the same direction. + */ +export async function navigateHistory( + get: () => QuoteStore, + set: (partial: Partial) => void, + direction: 'back' | 'forward' +): Promise { + const isBack = direction === 'back'; + try { + const { quotes, quoteHistory, historyIndex } = get(); + + // Already at the end of history in this direction + if (isBack ? historyIndex >= quoteHistory.length - 1 : historyIndex <= 0) { + return; + } + + const newIndex = isBack ? historyIndex + 1 : historyIndex - 1; + const quoteId = quoteHistory[newIndex]; + const quote = quotes.find((q) => q.id === quoteId); + + if (quote && !quote.isHidden) { + if (!(await persistCurrentQuote(quote, 'navigateHistory'))) { + useToastStore.getState().warning(CARD_NOT_REMEMBERED, { collapseRepeats: true }); + } + set({ currentQuote: quote, historyIndex: newIndex }); + await get().incrementViewCount(quote.id); + } else { + // Quote was deleted or hidden, skip it + set({ historyIndex: newIndex }); + await navigateHistory(get, set, direction); + } + } catch (error) { + logger.error(`Error going ${direction} in history`, error); + // Not `error` state: both renderers read that as "the load failed" and replace the page. + // A single action that failed has its toast. + useToastStore.getState().error(`Failed to navigate ${direction}. Please try again.`); + } +} + +/** + * Persists current filter settings. Goes through the settings store rather than storage directly: + * that path is serialized against other settings writes, checks the result, surfaces its own error + * toast, and notifies sync for each changed key (the quoteFilter* keys are not device-local). + */ +export async function persistFilterSettings(state: QuoteStore): Promise { + await useSettingsStore.getState().updateSettings({ + quoteFilterEnabledCategories: state.enabledCategories, + quoteFilterShowCustomQuotes: state.showCustomQuotes, + quoteFilterShowFavoritesOnly: state.showFavoritesOnly, + quoteFilterActiveCollectionIds: state.activeCollectionIds, + }); +} + +/** + * A write aimed at one quote. Both members carry a list storage has vouched for: `gone` never + * wrote, and a write that fails throws instead of answering. Announcing a gone id would mark it + * dirty, which pushes as a tombstone this device never made. + */ +type OneQuoteWrite = + | { kind: 'gone'; quotes: Quote[] } + | { kind: 'saved'; quotes: Quote[]; target: Quote }; + +export async function persistOneQuote( + quoteId: string, + change: (quote: Quote) => Quote +): Promise { + // Its own lock rather than updateQuotes, so a gone id skips the write entirely: rewriting an + // identical list can still fail, and the caller would report that instead of the gone. + return withCollectionLock('quotes', async (): Promise => { + const current = await getQuotes(); + // Boxed because TypeScript does not track an assignment inside the callback: a bare `let` + // narrows to `never` past the guard, and the compiler stops checking `target` altogether. + const hit: { target: Quote | null } = { target: null }; + const quotes = current.map((q) => { + if (q.id !== quoteId) { + return q; + } + hit.target = change(q); + return hit.target; + }); + if (hit.target === null) { + return { kind: 'gone', quotes: current }; + } + assertPersisted(await setQuotes(quotes)); + return { kind: 'saved', quotes, target: hit.target }; + }); +} + +/** + * Seed quotes do reach the account — the enroll backfill claims every stored id — but only a + * custom quote's edit is announced from here, so a seed edit stays on this device. + */ +export function syncsLocalEdits(quote: Quote | null): boolean { + return quote?.isCustom === true; +} + +/** + * The card's own key. `initialize` reads back only its id and re-resolves it against the list, + * so losing this write matters only where the id changes AND the stored one still resolves: a + * writer that keeps the id, or one replacing an id the list no longer holds, loses nothing. + * Answers rather than throwing: the list write has usually already landed, and `getStorageArea` + * rejects, so both exits have to end up in the same place. + */ +export async function persistCurrentQuote(quote: Quote, action: string): Promise { + try { + const result = await setCurrentQuote(quote); + if (result?.success !== true) { + logger.error(`${action}: could not persist the displayed quote`, result?.error, { + quoteId: quote.id, + }); + return false; + } + return true; + } catch (error) { + logger.error(`${action}: could not persist the displayed quote`, error, { quoteId: quote.id }); + return false; + } +} + +/** + * The card renders `currentQuote`, which no observer converges, so a writer that finds the quote + * gone has to move the card itself. + * + * Checked against `written` rather than trusted from the call site: a caller that reaches here + * with the quote still present would otherwise reroll the card for no reason. + */ +export async function refreshCardIfQuoteGone( + quoteId: string, + written: Quote[], + get: () => QuoteStore +): Promise { + const isGone = !written.some((q) => q.id === quoteId); + if (isGone && get().currentQuote?.id === quoteId) { + await get().refreshQuote(); + } +} + +/** The locked read no longer holds it: a pull deleted it between the user's click and the write. */ +export function reportQuoteGone(action: string, quoteId: string): void { + // error, not warn: the shipped logLevel is 'error', and this is where a "but it's right + // there" report starts. + logger.error(`${action}: quote ${quoteId} was gone before the write`); + // Collapsed: AddQuotesToCollectionModal applies its pending changes one quote at a time, so a + // pull that removed the selection would otherwise stack one identical toast per quote. + useToastStore.getState().warning('This quote no longer exists', { collapseRepeats: true }); +} + +/** Only `initialize` reads the card key, so a lost write shows up on the next tab, not this one. */ +export const CARD_NOT_REMEMBERED = + "Couldn't remember this quote — the next tab you open may still show the previous one."; + +/** The four fields the pick filters on, named so the two adjacent booleans cannot be transposed. */ +interface QuoteFilters { + enabledCategories: QuoteCategory[]; + showCustomQuotes: boolean; + showFavoritesOnly: boolean; + activeCollectionIds: string[]; +} + +export function pickQuote( + quotes: Quote[], + filters: QuoteFilters, + excludeId?: string +): Quote | null { + return getRandomQuote( + quotes, + excludeId, + filters.enabledCategories, + filters.showCustomQuotes, + filters.showFavoritesOnly, + filters.activeCollectionIds + ); +} diff --git a/packages/app/src/stores/quote-store/types.ts b/packages/app/src/stores/quote-store/types.ts new file mode 100644 index 00000000..12ac5dc1 --- /dev/null +++ b/packages/app/src/stores/quote-store/types.ts @@ -0,0 +1,93 @@ +import type { + BulkImportResult, + CSVQuoteRow, + Quote, + QuoteCategory, + QuoteCollection, +} from '@cuewise/shared'; + +/** A write aimed at one entity: 'gone' is not a failure, and a retry cannot fix it. */ +export type QuoteWriteOutcome = 'saved' | 'gone' | 'failed'; + +export interface QuoteStore { + quotes: Quote[]; + currentQuote: Quote | null; + isLoading: boolean; + error: string | null; + quoteHistory: string[]; // Array of quote IDs in viewing order + historyIndex: number; // Current position in history (0 = most recent) + enabledCategories: QuoteCategory[]; // Categories to show (persisted to settings) + showCustomQuotes: boolean; // Show custom quotes in filter (persisted to settings) + showFavoritesOnly: boolean; // Show only favorite quotes (persisted to settings) + + // Collections state + collections: QuoteCollection[]; + activeCollectionIds: string[]; // Enabled collection filters (persisted to settings) + + // Actions + initialize: () => Promise; + /** `userInitiated` warns when the card write is lost; unprompted ticks stay quiet. */ + refreshQuote: (options?: { userInitiated?: boolean }) => Promise; + goBack: () => Promise; + goForward: () => Promise; + canGoBack: () => boolean; + canGoForward: () => boolean; + toggleFavorite: (quoteId: string) => Promise; + hideQuote: (quoteId: string) => Promise; + unhideQuote: (quoteId: string) => Promise; + /** False when the write did not persist, so the form can keep what the user typed. */ + addCustomQuote: ( + text: string, + author: string, + category: QuoteCategory, + source?: string, + notes?: string + ) => Promise; + /** 'gone' when a pull deleted it first, 'failed' when the write did not persist. */ + editQuote: ( + quoteId: string, + updates: { + text?: string; + author?: string; + category?: QuoteCategory; + source?: string; + notes?: string; + } + ) => Promise; + deleteQuote: (quoteId: string) => Promise; + incrementViewCount: (quoteId: string) => Promise; + setEnabledCategories: (categories: QuoteCategory[]) => Promise; + toggleCategory: (category: QuoteCategory) => Promise; + toggleCustomQuotes: () => Promise; + toggleFavoritesOnly: () => Promise; + + // Bulk operations + /** False when the write did not persist, so the caller can keep the user's selection. */ + bulkDelete: (quoteIds: string[]) => Promise; + bulkToggleFavorite: (quoteIds: string[], setFavorite: boolean) => Promise; + bulkToggleHidden: (quoteIds: string[], setHidden: boolean) => Promise; + + // Restoration operations + restoreMissingQuotes: () => Promise<{ restored: number }>; + resetAllQuotes: () => Promise; + getMissingSeedQuoteCount: () => number; + + // Collection operations + createCollection: (name: string, description?: string) => Promise; + /** 'gone' when a pull deleted it first — the store has already told the user, so do not retry. */ + updateCollection: ( + id: string, + updates: Partial> + ) => Promise; + deleteCollection: (id: string) => Promise; + /** 'gone' when a pull deleted the quote first, 'failed' when the write did not persist. */ + addQuoteToCollection: (quoteId: string, collectionId: string) => Promise; + removeQuoteFromCollection: (quoteId: string, collectionId: string) => Promise; + addQuotesToCollection: (quoteIds: string[], collectionId: string) => Promise; + toggleCollection: (collectionId: string) => Promise; + setActiveCollectionIds: (collectionIds: string[]) => Promise; + getQuotesInCollection: (collectionId: string) => Quote[]; + + // CSV Import + bulkAddQuotes: (quoteRows: CSVQuoteRow[], collectionId?: string) => Promise; +}