gramene-search 2.1.11 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/.claude/launch.json +11 -0
  2. package/.claude/settings.local.json +6 -1
  3. package/.env.example +6 -0
  4. package/.parcel-cache/13f2d5707e7af45c-RequestGraph +0 -0
  5. package/.parcel-cache/5ae0570a78c0dba3-AssetGraph +0 -0
  6. package/.parcel-cache/9ac092379278e465-BundleGraph +0 -0
  7. package/.parcel-cache/data.mdb +0 -0
  8. package/.parcel-cache/lock.mdb +0 -0
  9. package/.parcel-cache/snapshot-13f2d5707e7af45c.txt +2 -2
  10. package/dist/index.js +2065 -154
  11. package/dist/index.js.map +1 -1
  12. package/package.json +5 -2
  13. package/src/bundles/api.js +10 -4
  14. package/src/bundles/exprViz.js +97 -1
  15. package/src/bundles/index.js +4 -1
  16. package/src/bundles/ontologyEnrichment.js +14 -1
  17. package/src/bundles/savedViews.js +335 -0
  18. package/src/bundles/uiViewState.js +174 -0
  19. package/src/bundles/viewSnapshot.js +313 -0
  20. package/src/bundles/views.js +22 -0
  21. package/src/components/Auth.js +23 -3
  22. package/src/components/SaveView.js +157 -0
  23. package/src/components/exprViz/ExprVizView.js +16 -11
  24. package/src/components/exprViz/ParallelCoordsPlot.js +15 -0
  25. package/src/components/results/GeneList.js +45 -15
  26. package/src/components/results/OntologyEnrichment.js +13 -6
  27. package/src/components/results/details/BAR.js +148 -0
  28. package/src/components/results/details/Expression.js +43 -11
  29. package/src/components/results/details/Homology.js +170 -32
  30. package/src/components/results/details/Pathways.js +4 -2
  31. package/src/components/results/details/Sequences.js +24 -8
  32. package/src/demo.js +22 -17
  33. package/src/index.js +2 -1
  34. package/src/suppressDevWarnings.js +13 -0
  35. package/src/utils/bootView.js +38 -0
package/package.json CHANGED
@@ -1,8 +1,12 @@
1
1
  {
2
2
  "name": "gramene-search",
3
- "version": "2.1.11",
3
+ "version": "2.2.0",
4
4
  "description": "search wrapper for gramene",
5
5
  "main": "dist/index.js",
6
+ "alias": {
7
+ "react": "./node_modules/react",
8
+ "react-dom": "./node_modules/react-dom"
9
+ },
6
10
  "targets": {
7
11
  "main": {
8
12
  "source": "src/index.js"
@@ -42,7 +46,6 @@
42
46
  "flat-to-nested": "^1.1.1",
43
47
  "gramene-bins-client": "^2.3.3",
44
48
  "gramene-dbxrefs": "^3.0.15",
45
- "gramene-efp-browser": "^1.0.11",
46
49
  "gramene-genetree-vis": "^4.2.11",
47
50
  "gramene-mdview": "^2.0.8",
48
51
  "gramene-search-vis": "^4.2.5",
@@ -1,7 +1,13 @@
1
1
  import { createAsyncResourceBundle, createSelector } from 'redux-bundler'
2
2
  import _ from 'lodash'
3
- import binsClient from "gramene-bins-client";
4
- import treesClient from "gramene-trees-client";
3
+ // Import the leaf modules directly (not the package index) so we don't
4
+ // trigger gramene-bins-client/index.js and gramene-trees-client/index.js,
5
+ // each of which eagerly require()s ./src/promise -> gramene-search-client
6
+ // -> grameneSwaggerClient.js, whose top-level IIFE fires a GET to /swagger
7
+ // at module load. We never use the promise path, so the fetch was pure
8
+ // waste — two requests per page load before this change.
9
+ import bins from "gramene-bins-client/src/bins";
10
+ import taxonomy from "gramene-trees-client/src/taxonomy";
5
11
  import {build} from "gramene-taxonomy-with-genomes";
6
12
 
7
13
  const facets = [
@@ -458,8 +464,8 @@ const grameneTaxDist = {
458
464
  grameneTaxonomy[tid].name = map.display_name;
459
465
  });
460
466
  const binnedResults = formatFacetCountsForViz(grameneSearch.facet_counts.facet_fields.fixed_1000__bin);
461
- let speciesTree = treesClient.taxonomy.tree(Object.values(grameneTaxonomy));
462
- let binMapper = binsClient.bins(grameneMaps);
467
+ let speciesTree = taxonomy.tree(Object.values(grameneTaxonomy));
468
+ let binMapper = bins(grameneMaps);
463
469
  let taxDist = build(binMapper, speciesTree);
464
470
  taxDist.setBinType('fixed',1000);
465
471
  taxDist.setResults(binnedResults);
@@ -6,8 +6,12 @@ import { createSelector } from 'redux-bundler';
6
6
  // activeTaxon: <taxon_id|null>,
7
7
  // byTaxon: {
8
8
  // [taxon_id]: {
9
- // selectedFields: [<solr field name>...],
9
+ // selectedFields: [<solr field name>...], // order = column/axis order
10
10
  // fieldsModalOpen: <bool>,
11
+ // vizMode: 'heatmap'|'parallel', // persisted view config
12
+ // scale: 'linear'|'log', // persisted view config
13
+ // brushes: { [field]: [lo, hi] }, // persisted parallel-coords brushes
14
+ // pendingLoad: <bool>, // transient: a restored view wants a re-fetch
11
15
  // fetch: { status, offset, total, signature, requestId, error },
12
16
  // rows: [<doc>...]
13
17
  // }
@@ -54,6 +58,10 @@ const exprViz = {
54
58
  return {
55
59
  selectedFields: [],
56
60
  fieldsModalOpen: false,
61
+ vizMode: 'heatmap',
62
+ scale: 'linear',
63
+ brushes: {},
64
+ pendingLoad: false,
57
65
  fetch: { status: 'idle', offset: 0, total: 0, signature: null, requestId: 0, error: null },
58
66
  rows: [],
59
67
  availableAttrs: null,
@@ -96,12 +104,58 @@ const exprViz = {
96
104
  [payload.taxon]: {
97
105
  ...t,
98
106
  selectedFields: payload.fields,
107
+ // A new field selection invalidates any prior brushes (they
108
+ // reference axes that may no longer exist) and the loaded rows.
109
+ brushes: {},
99
110
  rows: [],
100
111
  fetch: { status: 'idle', offset: 0, total: 0, signature: null, requestId: 0, error: null }
101
112
  }
102
113
  }
103
114
  };
104
115
  }
116
+ case 'EXPRVIZ_VIZMODE_SET': {
117
+ const t = ensureTaxon(state, payload.taxon);
118
+ return {
119
+ ...state,
120
+ byTaxon: { ...state.byTaxon, [payload.taxon]: { ...t, vizMode: payload.vizMode } }
121
+ };
122
+ }
123
+ case 'EXPRVIZ_SCALE_SET': {
124
+ const t = ensureTaxon(state, payload.taxon);
125
+ return {
126
+ ...state,
127
+ byTaxon: { ...state.byTaxon, [payload.taxon]: { ...t, scale: payload.scale } }
128
+ };
129
+ }
130
+ case 'EXPRVIZ_BRUSHES_SET': {
131
+ const t = ensureTaxon(state, payload.taxon);
132
+ return {
133
+ ...state,
134
+ byTaxon: { ...state.byTaxon, [payload.taxon]: { ...t, brushes: payload.brushes || {} } }
135
+ };
136
+ }
137
+ case 'EXPRVIZ_RESTORED': {
138
+ // Re-apply persisted view config from a saved-view snapshot. Async
139
+ // data (rows) is NOT in the snapshot — instead each taxon with a
140
+ // field selection is flagged pendingLoad so reactExprVizRestoreLoad
141
+ // re-fetches it once the search context is ready.
142
+ const byTaxon = { ...state.byTaxon };
143
+ const cfg = payload.byTaxon || {};
144
+ for (const tid of Object.keys(cfg)) {
145
+ const base = ensureTaxon(state, tid);
146
+ const c = cfg[tid] || {};
147
+ const selectedFields = Array.isArray(c.selectedFields) ? c.selectedFields : [];
148
+ byTaxon[tid] = {
149
+ ...base,
150
+ selectedFields,
151
+ vizMode: c.vizMode || 'heatmap',
152
+ scale: c.scale || 'linear',
153
+ brushes: c.brushes || {},
154
+ pendingLoad: selectedFields.length > 0
155
+ };
156
+ }
157
+ return { ...state, activeTaxon: payload.activeTaxon || state.activeTaxon, byTaxon };
158
+ }
105
159
  case 'EXPRVIZ_FIELDS_REORDERED': {
106
160
  const t = state.byTaxon[payload.taxon];
107
161
  if (!t) return state;
@@ -128,6 +182,7 @@ const exprViz = {
128
182
  ...state.byTaxon,
129
183
  [payload.taxon]: {
130
184
  ...t,
185
+ pendingLoad: false,
131
186
  fetch: { ...t.fetch, status: 'loading', signature: payload.signature, requestId: payload.requestId, error: null }
132
187
  }
133
188
  }
@@ -309,6 +364,24 @@ const exprViz = {
309
364
  doReorderExprVizFields: (taxon, fields) => ({ dispatch }) =>
310
365
  dispatch({ type: 'EXPRVIZ_FIELDS_REORDERED', payload: { taxon, fields } }),
311
366
 
367
+ doSetExprVizVizMode: (taxon, vizMode) => ({ dispatch }) =>
368
+ dispatch({ type: 'EXPRVIZ_VIZMODE_SET', payload: { taxon, vizMode } }),
369
+
370
+ doSetExprVizScale: (taxon, scale) => ({ dispatch }) =>
371
+ dispatch({ type: 'EXPRVIZ_SCALE_SET', payload: { taxon, scale } }),
372
+
373
+ doSetExprVizBrushes: (taxon, brushes) => ({ dispatch }) =>
374
+ dispatch({ type: 'EXPRVIZ_BRUSHES_SET', payload: { taxon, brushes } }),
375
+
376
+ // Re-apply persisted exprViz view config from a saved-view snapshot. Called
377
+ // by viewSnapshot's doApplyViewSnapshot after filters/views have been
378
+ // restored, so the re-fetch (driven by reactExprVizRestoreLoad) sees the
379
+ // restored query context.
380
+ doApplyExprVizSnapshot: snap => ({ dispatch }) => {
381
+ if (!snap || typeof snap !== 'object') return;
382
+ dispatch({ type: 'EXPRVIZ_RESTORED', payload: { activeTaxon: snap.activeTaxon || null, byTaxon: snap.byTaxon || {} } });
383
+ },
384
+
312
385
  doFetchExprVizData: taxon => ({ dispatch, store }) => {
313
386
  const ev = store.selectExprViz();
314
387
  const t = ev.byTaxon[taxon];
@@ -413,6 +486,29 @@ const exprViz = {
413
486
  }
414
487
  ),
415
488
 
489
+ // After a saved view is applied, EXPRVIZ_RESTORED flags each taxon that had
490
+ // a field selection with pendingLoad. Once the search context is live (not
491
+ // 'init') and the view is on, re-fetch that taxon's rows. Fires one taxon at
492
+ // a time; EXPRVIZ_FETCH_STARTED clears the flag so this won't loop. Robust to
493
+ // a GRAMENE_SEARCH_CLEARED that wipes rows mid-restore — the flag persists.
494
+ reactExprVizRestoreLoad: createSelector(
495
+ 'selectExprViz',
496
+ 'selectGrameneFiltersStatus',
497
+ 'selectGrameneViews',
498
+ (ev, filtersStatus, views) => {
499
+ if (!ev || filtersStatus === 'init') return;
500
+ const onView = views && views.options && views.options.find(v => v.id === 'exprViz');
501
+ if (!onView || onView.show !== 'on') return;
502
+ for (const tid of Object.keys(ev.byTaxon || {})) {
503
+ const t = ev.byTaxon[tid];
504
+ if (t && t.pendingLoad && t.selectedFields.length > 0
505
+ && t.rows.length === 0 && t.fetch.status !== 'loading') {
506
+ return { actionCreator: 'doFetchExprVizData', args: [tid] };
507
+ }
508
+ }
509
+ }
510
+ ),
511
+
416
512
  selectExprViz: state => state.exprViz,
417
513
  selectExprVizPivot: state => state.exprViz.pivot,
418
514
  selectExprVizActiveTaxon: state => state.exprViz.activeTaxon,
@@ -8,5 +8,8 @@ import exporterBundle from './exporter'
8
8
  import ontologiesBundle from './ontologies'
9
9
  import exprVizBundle from './exprViz'
10
10
  import ontologyEnrichmentBundle from './ontologyEnrichment'
11
+ import uiViewStateBundle from './uiViewState'
12
+ import viewSnapshotBundle from './viewSnapshot'
13
+ import savedViewsBundle from './savedViews'
11
14
 
12
- export default [...apiBundles, docsBundle, filterBundle, viewsBundle, genomesBundle, fieldCatalogBundle, exporterBundle, ontologiesBundle, exprVizBundle, ontologyEnrichmentBundle];
15
+ export default [...apiBundles, docsBundle, filterBundle, viewsBundle, genomesBundle, fieldCatalogBundle, exporterBundle, ontologiesBundle, exprVizBundle, ontologyEnrichmentBundle, uiViewStateBundle, viewSnapshotBundle, savedViewsBundle];
@@ -70,7 +70,10 @@ const ontologyEnrichment = {
70
70
  maxGSSize: 500,
71
71
  mostSpecific: false,
72
72
  ontology: 'all',
73
- search: ''
73
+ search: '',
74
+ // Per-section table sort, keyed by ontology section id (e.g.
75
+ // 'GO:biological_process'): { [sectionKey]: { key, dir } }.
76
+ sort: {}
74
77
  }
75
78
  };
76
79
 
@@ -196,6 +199,16 @@ const ontologyEnrichment = {
196
199
  doSetOntologyEnrichmentUI: patch => ({ dispatch }) =>
197
200
  dispatch({ type: 'ONTOLOGY_ENRICHMENT_UI_SET', payload: patch }),
198
201
 
202
+ // Re-apply persisted view config from a saved-view snapshot. Setting the
203
+ // active taxon (last) lets reactOntologyEnrichmentFetch re-fetch the
204
+ // foreground/background facets for the restored filters — no bulk data is
205
+ // carried in the snapshot.
206
+ doApplyOntologyEnrichmentSnapshot: snap => ({ dispatch }) => {
207
+ if (!snap || typeof snap !== 'object') return;
208
+ if (snap.ui) dispatch({ type: 'ONTOLOGY_ENRICHMENT_UI_SET', payload: snap.ui });
209
+ if (snap.activeTaxon) dispatch({ type: 'ONTOLOGY_ENRICHMENT_ACTIVE_TAXON_SET', payload: snap.activeTaxon });
210
+ },
211
+
199
212
  doFetchOntologyEnrichmentForeground: taxon => ({ dispatch, store }) => {
200
213
  const q = store.selectGrameneFiltersQueryString();
201
214
  const signature = fgSig(q, taxon);
@@ -0,0 +1,335 @@
1
+ import bootViewFromUrl from '../utils/bootView';
2
+
3
+ // Client for the saved-views API ({api}/saved_views — see gramene-swagger
4
+ // phase 3b). Mirrors the gene_lists pattern: Firebase Bearer ID token,
5
+ // hash-based addressing, public vs private scope.
6
+ //
7
+ // Action creators all return Promises so calling UI (modal, boot path,
8
+ // list view) can await them and surface success/error inline.
9
+ //
10
+ // Dev-mode mock: set `window.__SAVED_VIEWS_MOCK__ = true` in the console
11
+ // and saves/fetches go through localStorage instead of the network. Lets us
12
+ // drive Phase 4 (UI) without waiting on Phase 3b (server). Production
13
+ // builds never touch the mock unless the flag is set at runtime, so this
14
+ // is safe to ship.
15
+
16
+ const STORAGE_PREFIX = 'gramene_saved_view_mock_v1::';
17
+
18
+ const initialState = {
19
+ saving: false,
20
+ saveError: null,
21
+ lastSavedHash: null,
22
+
23
+ fetching: false,
24
+ fetchError: null,
25
+ lastFetched: null, // { hash, snapshot, meta }
26
+
27
+ privateList: null, // null = not yet fetched
28
+ publicList: null,
29
+ listError: null
30
+ };
31
+
32
+ const savedViews = {
33
+ name: 'savedViews',
34
+
35
+ getReducer: () => (state = initialState, {type, payload}) => {
36
+ switch (type) {
37
+ case 'SAVED_VIEW_SAVE_STARTED':
38
+ return {...state, saving: true, saveError: null};
39
+ case 'SAVED_VIEW_SAVE_SUCCEEDED':
40
+ return {...state, saving: false, lastSavedHash: payload.hash};
41
+ case 'SAVED_VIEW_SAVE_FAILED':
42
+ return {...state, saving: false, saveError: payload.error};
43
+
44
+ case 'SAVED_VIEW_FETCH_STARTED':
45
+ return {...state, fetching: true, fetchError: null};
46
+ case 'SAVED_VIEW_FETCH_SUCCEEDED':
47
+ return {...state, fetching: false, lastFetched: payload};
48
+ case 'SAVED_VIEW_FETCH_FAILED':
49
+ return {...state, fetching: false, fetchError: payload.error};
50
+
51
+ case 'SAVED_VIEW_LIST_RECEIVED':
52
+ return {
53
+ ...state,
54
+ [payload.kind === 'public' ? 'publicList' : 'privateList']: payload.rows,
55
+ listError: null
56
+ };
57
+ case 'SAVED_VIEW_LIST_FAILED':
58
+ return {...state, listError: payload.error};
59
+
60
+ case 'SAVED_VIEW_RESET':
61
+ return {...initialState};
62
+
63
+ default:
64
+ return state;
65
+ }
66
+ },
67
+
68
+ selectSavedViews: state => state.savedViews,
69
+
70
+ // POST a snapshot. Returns Promise<{hash, shareUrl}> on success.
71
+ // `user` is a Firebase user object (must respond to .getIdToken()).
72
+ doSaveView: ({user, label, description, isPublic}) => async ({dispatch, store}) => {
73
+ dispatch({type: 'SAVED_VIEW_SAVE_STARTED'});
74
+ try {
75
+ const snapshot = store.selectViewSnapshot();
76
+ const hash = await computeContentHash(snapshot);
77
+ const site = (store.selectConfiguration && store.selectConfiguration().id) || '';
78
+ const token = (user && user.getIdToken) ? await user.getIdToken() : null;
79
+ if (!token) throw new Error('Not signed in');
80
+
81
+ const meta = {
82
+ hash, label, site, isPublic: !!isPublic,
83
+ description: description || ''
84
+ };
85
+
86
+ if (useMock()) {
87
+ mockSave({...meta, state: snapshot, uid: user.uid || 'mock', createdAt: new Date().toISOString()});
88
+ } else {
89
+ const api = store.selectGrameneAPI();
90
+ const res = await fetch(`${api}/saved_views`, {
91
+ method: 'POST',
92
+ headers: {
93
+ 'Content-Type': 'application/json',
94
+ Authorization: `Bearer ${token}`
95
+ },
96
+ body: JSON.stringify({...meta, state: snapshot})
97
+ });
98
+ if (!res.ok) throw new Error(`Save failed (${res.status})`);
99
+ }
100
+
101
+ const shareUrl = buildShareUrl(hash);
102
+ dispatch({type: 'SAVED_VIEW_SAVE_SUCCEEDED', payload: {hash}});
103
+ return {hash, shareUrl};
104
+ } catch (err) {
105
+ dispatch({type: 'SAVED_VIEW_SAVE_FAILED', payload: {error: err.message || String(err)}});
106
+ throw err;
107
+ }
108
+ },
109
+
110
+ // GET a snapshot by share hash. Anonymous-OK for public views; token is
111
+ // optional and only sent if the caller provides a user.
112
+ // Returns Promise<{snapshot, meta}>.
113
+ doFetchView: ({hash, user}) => async ({dispatch, store}) => {
114
+ dispatch({type: 'SAVED_VIEW_FETCH_STARTED'});
115
+ try {
116
+ let row;
117
+ if (useMock()) {
118
+ row = mockFetch(hash);
119
+ if (!row) throw new Error(`No saved view with hash ${hash}`);
120
+ } else {
121
+ const api = store.selectGrameneAPI();
122
+ const headers = {Accept: 'application/json'};
123
+ if (user && user.getIdToken) {
124
+ try { headers.Authorization = `Bearer ${await user.getIdToken()}`; }
125
+ catch (_) { /* anonymous fallback */ }
126
+ }
127
+ const res = await fetch(`${api}/saved_views?hash=${encodeURIComponent(hash)}`, {headers});
128
+ if (res.status === 401) throw new Error('Sign in to load this private view.');
129
+ if (res.status === 404) throw new Error('Saved view not found (it may have been deleted).');
130
+ if (!res.ok) throw new Error(`Fetch failed (${res.status})`);
131
+ row = await res.json();
132
+ }
133
+ const out = {
134
+ hash,
135
+ snapshot: row.state,
136
+ meta: {
137
+ label: row.label,
138
+ description: row.description || '',
139
+ site: row.site,
140
+ isPublic: !!row.isPublic,
141
+ owner: row.owner || null,
142
+ createdAt: row.createdAt || null
143
+ }
144
+ };
145
+ dispatch({type: 'SAVED_VIEW_FETCH_SUCCEEDED', payload: out});
146
+ return out;
147
+ } catch (err) {
148
+ dispatch({type: 'SAVED_VIEW_FETCH_FAILED', payload: {error: err.message || String(err)}});
149
+ throw err;
150
+ }
151
+ },
152
+
153
+ // List views for the current site. `scope` is 'public' (anonymous-OK)
154
+ // or 'private' (requires `user`).
155
+ doListSavedViews: ({scope, user}) => async ({dispatch, store}) => {
156
+ try {
157
+ const site = (store.selectConfiguration && store.selectConfiguration().id) || '';
158
+ let rows;
159
+ if (useMock()) {
160
+ rows = mockList({site, scope, uid: user && user.uid});
161
+ } else {
162
+ const api = store.selectGrameneAPI();
163
+ const headers = {Accept: 'application/json'};
164
+ if (scope === 'private') {
165
+ if (!user || !user.getIdToken) throw new Error('Not signed in');
166
+ headers.Authorization = `Bearer ${await user.getIdToken()}`;
167
+ }
168
+ const url = `${api}/saved_views?site=${encodeURIComponent(site)}&isPublic=${scope === 'public'}`;
169
+ const res = await fetch(url, {headers});
170
+ if (!res.ok) throw new Error(`List failed (${res.status})`);
171
+ rows = await res.json();
172
+ }
173
+ dispatch({type: 'SAVED_VIEW_LIST_RECEIVED', payload: {kind: scope, rows}});
174
+ return rows;
175
+ } catch (err) {
176
+ dispatch({type: 'SAVED_VIEW_LIST_FAILED', payload: {error: err.message || String(err)}});
177
+ throw err;
178
+ }
179
+ },
180
+
181
+ // PATCH label / isPublic on a view I own. Mirrors updateList.
182
+ doUpdateSavedView: ({viewId, user, label, isPublic}) => async ({store}) => {
183
+ const updates = {};
184
+ if (typeof label === 'string') updates.label = label;
185
+ if (typeof isPublic === 'boolean') updates.isPublic = isPublic;
186
+ if (!Object.keys(updates).length) return;
187
+
188
+ if (useMock()) {
189
+ mockUpdate(viewId, updates);
190
+ return {viewId, updates};
191
+ }
192
+ const token = await user.getIdToken();
193
+ const api = store.selectGrameneAPI();
194
+ const res = await fetch(`${api}/saved_views?viewId=${encodeURIComponent(viewId)}`, {
195
+ method: 'PATCH',
196
+ headers: {'Content-Type': 'application/json', Authorization: `Bearer ${token}`},
197
+ body: JSON.stringify(updates)
198
+ });
199
+ if (!res.ok) throw new Error(`Update failed (${res.status})`);
200
+ return res.json();
201
+ },
202
+
203
+ // DELETE one of my saved views. Mirrors deleteList.
204
+ doDeleteSavedView: ({viewId, user}) => async ({store}) => {
205
+ if (useMock()) {
206
+ mockDelete(viewId);
207
+ return {viewId};
208
+ }
209
+ const token = await user.getIdToken();
210
+ const api = store.selectGrameneAPI();
211
+ const res = await fetch(`${api}/saved_views?viewId=${encodeURIComponent(viewId)}`, {
212
+ method: 'DELETE',
213
+ headers: {Authorization: `Bearer ${token}`}
214
+ });
215
+ if (!res.ok) throw new Error(`Delete failed (${res.status})`);
216
+ return res.json();
217
+ },
218
+
219
+ doResetSavedViewState: () => ({dispatch}) => dispatch({type: 'SAVED_VIEW_RESET'}),
220
+
221
+ // Connect-friendly wrapper around bootViewFromUrl. Auth.js calls this on
222
+ // each auth-state emission, passing the current Firebase user (or null);
223
+ // bootView.js no-ops when ?view= isn't in the URL, so this is cheap to
224
+ // call repeatedly.
225
+ doBootSharedView: ({user} = {}) => ({store}) => {
226
+ return bootViewFromUrl(store, {user});
227
+ }
228
+ };
229
+
230
+ // ── helpers ─────────────────────────────────────────────────────────────
231
+
232
+ // Test/dev: either set the runtime-only flag (`window.__SAVED_VIEWS_MOCK__ = true`,
233
+ // cleared on reload), or persist via localStorage (`localStorage.setItem(
234
+ // '__SAVED_VIEWS_MOCK__', 'true')`, survives reloads — needed for the
235
+ // share-link round-trip which navigates).
236
+ function useMock() {
237
+ if (typeof window === 'undefined') return false;
238
+ if (window.__SAVED_VIEWS_MOCK__) return true;
239
+ try { return typeof localStorage !== 'undefined' && localStorage.getItem('__SAVED_VIEWS_MOCK__') === 'true'; }
240
+ catch (_) { return false; }
241
+ }
242
+
243
+ function buildShareUrl(hash) {
244
+ if (typeof window === 'undefined') return `?view=${hash}`;
245
+ const u = new URL(window.location.href);
246
+ u.search = '';
247
+ u.searchParams.set('view', hash);
248
+ return u.toString();
249
+ }
250
+
251
+ // Content-addressable, ~72-bit short hash. The snapshot's `capturedAt` is
252
+ // excluded from the hash input so two saves of the same view by the same
253
+ // user produce the same hash (and the server's $setOnInsert preserves the
254
+ // original createdAt).
255
+ async function computeContentHash(snapshot) {
256
+ const forHash = {...snapshot, capturedAt: undefined};
257
+ const text = canonicalize(forHash);
258
+ if (typeof crypto !== 'undefined' && crypto.subtle) {
259
+ const buf = new TextEncoder().encode(text);
260
+ const digest = await crypto.subtle.digest('SHA-256', buf);
261
+ const bytes = new Uint8Array(digest);
262
+ let b64 = '';
263
+ for (let i = 0; i < bytes.length; i++) b64 += String.fromCharCode(bytes[i]);
264
+ return btoa(b64)
265
+ .replace(/\+/g, '-')
266
+ .replace(/\//g, '_')
267
+ .replace(/=+$/, '')
268
+ .slice(0, 12);
269
+ }
270
+ // Last-resort fallback for environments without WebCrypto.
271
+ let h = 0;
272
+ for (let i = 0; i < text.length; i++) h = ((h << 5) - h + text.charCodeAt(i)) | 0;
273
+ return ('h' + (h >>> 0).toString(36)).slice(0, 12);
274
+ }
275
+
276
+ // Stable stringify: sort object keys recursively so semantically-equivalent
277
+ // snapshots hash identically.
278
+ function canonicalize(v) {
279
+ if (v === null || typeof v !== 'object') return JSON.stringify(v);
280
+ if (Array.isArray(v)) return '[' + v.map(canonicalize).join(',') + ']';
281
+ const keys = Object.keys(v).filter(k => v[k] !== undefined).sort();
282
+ return '{' + keys.map(k => JSON.stringify(k) + ':' + canonicalize(v[k])).join(',') + '}';
283
+ }
284
+
285
+ // ── mock backend (localStorage) ─────────────────────────────────────────
286
+
287
+ function mockSave(row) {
288
+ if (typeof localStorage === 'undefined') return;
289
+ localStorage.setItem(STORAGE_PREFIX + row.hash, JSON.stringify({...row, _id: row.hash + ' ' + row.uid}));
290
+ }
291
+ function mockFetch(hash) {
292
+ if (typeof localStorage === 'undefined') return null;
293
+ const raw = localStorage.getItem(STORAGE_PREFIX + hash);
294
+ return raw ? JSON.parse(raw) : null;
295
+ }
296
+ function mockList({site, scope, uid}) {
297
+ if (typeof localStorage === 'undefined') return [];
298
+ const out = [];
299
+ for (let i = 0; i < localStorage.length; i++) {
300
+ const k = localStorage.key(i);
301
+ if (!k || !k.startsWith(STORAGE_PREFIX)) continue;
302
+ const row = JSON.parse(localStorage.getItem(k));
303
+ if (row.site !== site) continue;
304
+ if (scope === 'public' && !row.isPublic) continue;
305
+ if (scope === 'private' && (row.isPublic || row.uid !== uid)) continue;
306
+ out.push(row);
307
+ }
308
+ return out.sort((a, b) => (b.createdAt || '').localeCompare(a.createdAt || ''));
309
+ }
310
+ function mockUpdate(viewId, updates) {
311
+ if (typeof localStorage === 'undefined') return;
312
+ for (let i = 0; i < localStorage.length; i++) {
313
+ const k = localStorage.key(i);
314
+ if (!k || !k.startsWith(STORAGE_PREFIX)) continue;
315
+ const row = JSON.parse(localStorage.getItem(k));
316
+ if (row._id === viewId) {
317
+ localStorage.setItem(k, JSON.stringify({...row, ...updates}));
318
+ return;
319
+ }
320
+ }
321
+ }
322
+ function mockDelete(viewId) {
323
+ if (typeof localStorage === 'undefined') return;
324
+ for (let i = 0; i < localStorage.length; i++) {
325
+ const k = localStorage.key(i);
326
+ if (!k || !k.startsWith(STORAGE_PREFIX)) continue;
327
+ const row = JSON.parse(localStorage.getItem(k));
328
+ if (row._id === viewId) {
329
+ localStorage.removeItem(k);
330
+ return;
331
+ }
332
+ }
333
+ }
334
+
335
+ export default savedViews;