gramene-search 2.1.10 → 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 (41) hide show
  1. package/.claude/launch.json +11 -0
  2. package/.claude/settings.local.json +21 -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.css +1 -4
  11. package/dist/index.css.map +1 -1
  12. package/dist/index.js +2308 -433
  13. package/dist/index.js.map +1 -1
  14. package/package.json +5 -2
  15. package/src/bundles/api.js +24 -42
  16. package/src/bundles/docs.js +2 -1
  17. package/src/bundles/exprViz.js +97 -1
  18. package/src/bundles/index.js +4 -1
  19. package/src/bundles/ontologyEnrichment.js +14 -1
  20. package/src/bundles/savedViews.js +335 -0
  21. package/src/bundles/uiViewState.js +174 -0
  22. package/src/bundles/viewSnapshot.js +313 -0
  23. package/src/bundles/views.js +24 -2
  24. package/src/components/Auth.js +23 -3
  25. package/src/components/SaveView.js +157 -0
  26. package/src/components/exprViz/ExprVizView.js +16 -11
  27. package/src/components/exprViz/ParallelCoordsPlot.js +15 -0
  28. package/src/components/results/GeneList.js +45 -49
  29. package/src/components/results/OntologyEnrichment.js +13 -6
  30. package/src/components/results/TaxDist.js +11 -0
  31. package/src/components/results/details/BAR.js +148 -0
  32. package/src/components/results/details/Expression.js +50 -14
  33. package/src/components/results/details/Homology.js +171 -39
  34. package/src/components/results/details/Pathways.js +4 -2
  35. package/src/components/results/details/Sequences.js +24 -8
  36. package/src/components/results/details/VEP.js +65 -19
  37. package/src/components/styles.css +1 -4
  38. package/src/demo.js +30 -13
  39. package/src/index.js +2 -1
  40. package/src/suppressDevWarnings.js +13 -0
  41. 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.10",
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 = [
@@ -220,29 +226,6 @@ expressionSamples.reactExpressionSamples = createSelector(
220
226
  }
221
227
  );
222
228
 
223
- const curatedGenes = createAsyncResourceBundle( {
224
- name: 'curatedGenes',
225
- actionBaseType: 'CURATED_GENES',
226
- persist: true,
227
- staleAfter: 24 * 60 * 60 * 1000,
228
- getPromise: ({store}) => {
229
- return fetch(`https://devdata.gramene.org/curation/curations?rows=0&minFlagged=0&since=12-12-2029`)
230
- .then(res => res.json())
231
- .then(curation => _.keyBy(curation.genes, 'gene_id'))
232
- }
233
- });
234
- // Curated annotations are consumed by GeneList rows and Homology details,
235
- // both inside the gene-list view.
236
- curatedGenes.reactCuratedGenes = createSelector(
237
- 'selectCuratedGenesShouldUpdate',
238
- 'selectGrameneViewsOn',
239
- (shouldUpdate, viewsOn) => {
240
- if (!shouldUpdate) return;
241
- if (!viewsOn || !viewsOn.has('list')) return;
242
- return { actionCreator: 'doFetchCuratedGenes' }
243
- }
244
- );
245
-
246
229
  const grameneGermplasm = createAsyncResourceBundle( {
247
230
  name: 'grameneGermplasm',
248
231
  actionBaseType: 'GRAMENE_GERMPLASM',
@@ -387,35 +370,34 @@ const grameneSearch = createAsyncResourceBundle({
387
370
  const effectiveRows = noFilters ? 0 : rows;
388
371
  const facetField = noFilters ? noFilterFacets : facets;
389
372
 
390
- // Build the set of hidden taxon_ids (as strings for stable comparison
391
- // against either string or numeric facet values from Solr).
392
- const hiddenTaxa = new Set(Object.keys(m).filter(tid => m[tid].hidden));
373
+ // Set of taxon_ids the UI knows about keep only these in the response.
374
+ // Anything else (hidden taxa, or taxa that exist in Solr but not in this
375
+ // site's maps collection) is stripped so downstream consumers — notably
376
+ // the TaxDist Vis which throws on unknown tids — see a clean visible-only
377
+ // view. Mirrors the old server-side `fq=taxon_id:(visible)` behavior.
378
+ const visibleTaxaSet = new Set(visibleTaxa);
393
379
 
394
380
  // return fetch(`${store.selectGrameneAPI()}/search?q=${q}&facet.field=${facetField}&rows=${effectiveRows}&start=${offset}${fq}&stats=true&${statsFields.join('&')}`)
395
381
  return fetch(`${store.selectGrameneAPI()}/search?q=${q}&facet.field=${facetField}&rows=${effectiveRows}&start=${offset}${fq}`)
396
382
  .then(res => res.json())
397
383
  .then(res => {
398
- // Strip hidden-genome contributions client-side. With no fq filter
399
- // on the server, the response reflects every taxon; we subtract
400
- // hidden ones from totals and facets so the rest of the UI sees a
401
- // "visible-only" view.
402
- if (hiddenTaxa.size && res.facet_counts && res.facet_counts.facet_fields) {
384
+ if (visibleTaxaSet.size && res.facet_counts && res.facet_counts.facet_fields) {
403
385
  const tf = res.facet_counts.facet_fields.taxon_id;
404
386
  if (Array.isArray(tf)) {
405
- let hiddenGeneCount = 0;
387
+ let droppedGeneCount = 0;
406
388
  const kept = [];
407
389
  for (let i = 0; i < tf.length; i += 2) {
408
- if (hiddenTaxa.has(String(tf[i]))) {
409
- hiddenGeneCount += tf[i + 1];
410
- } else {
390
+ if (visibleTaxaSet.has(String(tf[i]))) {
411
391
  kept.push(tf[i], tf[i + 1]);
392
+ } else {
393
+ droppedGeneCount += tf[i + 1];
412
394
  }
413
395
  }
414
396
  res.facet_counts.facet_fields.taxon_id = kept;
415
397
  if (res.response) {
416
- res.response.numFound = Math.max(0, res.response.numFound - hiddenGeneCount);
398
+ res.response.numFound = Math.max(0, res.response.numFound - droppedGeneCount);
417
399
  if (Array.isArray(res.response.docs)) {
418
- res.response.docs = res.response.docs.filter(d => !hiddenTaxa.has(String(d.taxon_id)));
400
+ res.response.docs = res.response.docs.filter(d => visibleTaxaSet.has(String(d.taxon_id)));
419
401
  }
420
402
  }
421
403
  }
@@ -482,8 +464,8 @@ const grameneTaxDist = {
482
464
  grameneTaxonomy[tid].name = map.display_name;
483
465
  });
484
466
  const binnedResults = formatFacetCountsForViz(grameneSearch.facet_counts.facet_fields.fixed_1000__bin);
485
- let speciesTree = treesClient.taxonomy.tree(Object.values(grameneTaxonomy));
486
- let binMapper = binsClient.bins(grameneMaps);
467
+ let speciesTree = taxonomy.tree(Object.values(grameneTaxonomy));
468
+ let binMapper = bins(grameneMaps);
487
469
  let taxDist = build(binMapper, speciesTree);
488
470
  taxDist.setBinType('fixed',1000);
489
471
  taxDist.setResults(binnedResults);
@@ -628,4 +610,4 @@ const grameneParalogs = {
628
610
  // });
629
611
 
630
612
 
631
- export default [grameneSuggestions, grameneSearch, grameneGeneAttribs, grameneMaps, grameneTaxonomy, grameneTaxDist, grameneOrthologs, grameneParalogs, curatedGenes, grameneGermplasm, expressionSamples, expressionStudies];
613
+ export default [grameneSuggestions, grameneSearch, grameneGeneAttribs, grameneMaps, grameneTaxonomy, grameneTaxDist, grameneOrthologs, grameneParalogs, grameneGermplasm, expressionSamples, expressionStudies];
@@ -4,9 +4,10 @@ import { getConfiguredCache } from 'money-clip';
4
4
  // indefinitely (default maxAge of `Infinity`). The pathway set is small
5
5
  // and stable enough to keep around across sessions, so we bulk-load it
6
6
  // once with `?rows=-1` and reuse it instead of issuing per-id requests.
7
+ // Scoped to the subsite so per-site pathway corpora don't collide.
7
8
  const pathwayCache = getConfiguredCache({
8
9
  version: 1,
9
- name: 'gramene_pathways'
10
+ name: `gramene_pathways_${process.env.SUBSITE || 'default'}`
10
11
  });
11
12
 
12
13
  let pathwaysBulkPromise = null;
@@ -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;