PageSourceSearch

https://pressthink.org/j/rosen-archive/frontend/services/viewState.js?v=3.8.36

js pressthink.org collected 2026-10-02 04:26:11 UTC 8,305 bytes, 221 lines download raw bytes

1// Pure URL <-> view-state serialisation for the archive SPA.
2//
3// "View state" is the user's actual mental model of "where am I": the route,
4// route-specific context such as a selected entity, the active filters, and
5// the selected record. Today that state is split across router.js (hash route
6// plus query context) and App.js (filter/selection state), so this helper keeps
7// reloads and shared URLs from losing meaningful context.
8//
9// This module is the single serialisation helper called for in issue #133.
10// It is deliberately pure: no React, no reads or writes of window.location.
11// Every function takes plain values or URL strings and returns the same, so
12// the whole round-trip is unit-testable under `node --test` with no DOM.
13//
14// Phase 1 of #133: this helper only. The React `useViewState` provider that
15// consumes it is intentionally deferred until the entity-index hook
16// conventions land (#130 / PR #180), so both hooks share one shape.
17
18import { parseWikiHash, wikiPageHref } from './wikiService.js?v=3.8.36';
19import {
20  ABOUT_PRIVACY_HASH,
21  ABOUT_PRIVACY_SECTION,
22} from './privacyRoute.js?v=3.8.36';
23
24export {
25  ABOUT_PRIVACY_HASH,
26  getPrivacyDetailsHref,
27} from './privacyRoute.js?v=3.8.36';
28
29/** Hash route names. The default route renders with no hash at all. */
30export const ROUTES = {
31  archive: 'archive',
32  start: 'start',
33  folders: 'folders',
34  entities: 'entities',
35  dissertation: 'dissertation',
36  about: 'about',
37  analytics: 'analytics',
38  wiki: 'wiki',
39  desktop: 'desktop',
40  // Hidden route (#754). Nothing in the site navigation links to it, but it is
41  // a real route: it parses, serialises, and renders like every other one.
42  nowhere: 'nowhere',
43};
44
45export const DEFAULT_ROUTE = ROUTES.archive;
46
47// Query-param keys. Kept short so shared URLs stay readable. Array filters use
48// repeated keys (?cat=a&cat=b) rather than a comma-joined value: a category or
49// publication name containing a comma would silently corrupt a joined string,
50// and URLSearchParams handles repeated keys and percent-encoding for free.
51const PARAM = {
52  search: 'q',
53  era: 'era',
54  year: 'year',
55  type: 'type',
56  includeReplies: 'replies',
57  categories: 'cat',
58  publication: 'pub',
59  record: 'record',
60  entity: 'entity',
61};
62
63/**
64 * Return a fresh default filters object. A factory, not a shared constant, so
65 * callers can never mutate one default into another's state (the nested
66 * arrays make a shared frozen constant awkward to spread safely).
67 */
68export function defaultFilters() {
69  return {
70    search: '',
71    categories: [],
72    era: null,
73    year: null,
74    publication: [],
75    type: null,
76    includeReplies: false,
77  };
78}
79
80/** True for values that should be treated as "filter not set" when emitting. */
81function isUnset(value) {
82  return value === null || value === undefined || value === '';
83}
84
85/**
86 * Parse a full URL string into { route, filters, selectedRecord }.
87 * Unknown or empty hashes fall back to the default route. Missing filter
88 * params fall back to defaults, so any URL parses to a complete view state.
89 */
90export function parseViewState(href) {
91  const url = new URL(href);
92
93  // Hash carries the route. Strip a stray "?suffix" defensively even though
94  // navigateTo() never produces one (search params sit before the hash).
95  const hash = url.hash.replace(/^#/, '').split('?')[0];
96  const wikiHash = parseWikiHash(hash);
97  const desktopHash = hash.match(/^desktop(?:\/([a-z0-9]+(?:-[a-z0-9]+)*))?$/);
98  const isAboutPrivacy = hash === ABOUT_PRIVACY_HASH;
99  const route = desktopHash
100    ? ROUTES.desktop
101    : wikiHash.route === ROUTES.wiki
102    ? ROUTES.wiki
103    : isAboutPrivacy
104    ? ROUTES.about
105    : Object.values(ROUTES).includes(hash) ? hash : DEFAULT_ROUTE;
106  const routeParams = desktopHash?.[1]
107    ? { desktopAppId: desktopHash[1] }
108    : wikiHash.route === ROUTES.wiki && wikiHash.slug
109    ? { wikiSlug: wikiHash.slug }
110    : isAboutPrivacy
111    ? { aboutSection: ABOUT_PRIVACY_SECTION }
112    : {};
113
114  const params = url.searchParams;
115  const entityId = params.get(PARAM.entity);
116  const isEntityRoute = route === ROUTES.entities
117    || (route === ROUTES.desktop && routeParams.desktopAppId === 'entities');
118  if (isEntityRoute && /^[A-Za-z0-9_.:-]+$/.test(entityId || '')) {
119    routeParams.entityId = entityId;
120  }
121  const filters = defaultFilters();
122
123  const search = params.get(PARAM.search);
124  if (search !== null) filters.search = search;
125
126  const era = params.get(PARAM.era);
127  if (era !== null && era !== '') filters.era = era;
128
129  const type = params.get(PARAM.type);
130  if (type !== null && type !== '') filters.type = type;
131
132  // year stays a string: archive records store year as a string ("2025"), and
133  // App.js compares record.year to filters.year with strict inequality, so a
134  // coerced Number would silently match no records after a reload. The
135  // Number.isFinite guard only rejects a non-numeric param value.
136  const yearRaw = params.get(PARAM.year);
137  if (yearRaw !== null && yearRaw !== '' && Number.isFinite(Number(yearRaw))) {
138    filters.year = yearRaw;
139  }
140
141  filters.includeReplies = params.get(PARAM.includeReplies) === '1';
142  filters.categories = params.getAll(PARAM.categories);
143  filters.publication = params.getAll(PARAM.publication);
144
145  const selectedRecord = params.get(PARAM.record) || null;
146
147  return { route, routeParams, filters, selectedRecord };
148}
149
150/**
151 * Serialise a view state back into a URL string, preserving the origin and
152 * pathname of baseHref. Only non-default fields are emitted, so a pristine
153 * view produces a clean URL with no query string and no hash.
154 *
155 * @param {{route?: string, routeParams?: object, filters?: object, selectedRecord?: string|null}} viewState
156 * @param {string} baseHref - a URL whose origin and pathname are reused.
157 */
158export function viewStateToUrl(viewState, baseHref) {
159  if (typeof baseHref !== 'string' || baseHref === '') {
160    throw new TypeError('viewStateToUrl requires a baseHref URL string');
161  }
162
163  const url = new URL(baseHref);
164  url.search = '';
165  url.hash = '';
166
167  const route = viewState.route || DEFAULT_ROUTE;
168  const filters = { ...defaultFilters(), ...(viewState.filters || {}) };
169  const params = new URLSearchParams();
170
171  if (filters.search !== '') params.set(PARAM.search, filters.search);
172  if (!isUnset(filters.era)) params.set(PARAM.era, filters.era);
173  if (!isUnset(filters.type)) params.set(PARAM.type, filters.type);
174  if (!isUnset(filters.year) && Number.isFinite(Number(filters.year))) {
175    params.set(PARAM.year, String(filters.year));
176  }
177  if (filters.includeReplies === true) params.set(PARAM.includeReplies, '1');
178
179  for (const category of filters.categories || []) {
180    params.append(PARAM.categories, category);
181  }
182  for (const publication of filters.publication || []) {
183    params.append(PARAM.publication, publication);
184  }
185  if (!isUnset(viewState.selectedRecord)) {
186    params.set(PARAM.record, viewState.selectedRecord);
187  }
188
189  const wikiSlug = viewState.routeParams?.wikiSlug;
190  const desktopAppId = viewState.routeParams?.desktopAppId;
191  const entityId = viewState.routeParams?.entityId;
192  const aboutSection = viewState.routeParams?.aboutSection;
193  const isEntityRoute = route === ROUTES.entities
194    || (route === ROUTES.desktop && desktopAppId === 'entities');
195  if (isEntityRoute && /^[A-Za-z0-9_.:-]+$/.test(entityId || '')) {
196    params.set(PARAM.entity, entityId);
197  }
198  url.search = params.toString();
199  url.hash = route === DEFAULT_ROUTE ? ''
200    : route === ROUTES.about && aboutSection === ABOUT_PRIVACY_SECTION ? ABOUT_PRIVACY_HASH
201    : route === ROUTES.wiki && wikiSlug ? wikiPageHref(wikiSlug).slice(1)
202    : route === ROUTES.desktop && /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(desktopAppId || '')
203      ? `${ROUTES.desktop}/${desktopAppId}`
204    : route;
205  return url.toString();
206}
207
208/**
209 * Migrate a legacy ?view=dissertation|about URL to the hash-route form.
210 * Pure: returns the migrated URL string, or the input unchanged when there is
211 * nothing to migrate. A wiring-phase wrapper applies the result to history.
212 */
213export function migrateLegacyHref(href) {
214  const url = new URL(href);
215  const view = url.searchParams.get('view');
216  if (view !== 'dissertation' && view !== 'about') return href;
217
218  url.searchParams.delete('view');
219  url.hash = ROUTES[view];
220  return url.toString();
221}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.