Skip to content

Commit fff1f31

Browse files
committed
feat: add UserPreferences class to manage per-user CouchDB preferences
1 parent 44502f8 commit fff1f31

2 files changed

Lines changed: 230 additions & 0 deletions

File tree

CLAUDE.md

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
# visualizer-helper — Project Context
2+
3+
## What this is
4+
5+
A utility library consumed exclusively by views built on the [npellet/visualizer](https://github.com/npellet/visualizer) framework. Views run inside iframes; this library bridges visualizer's AMD module system with CouchDB (via rest-on-couch) and provides domain helpers for ELN, NMR, chemistry, biology, and spectroscopy.
6+
7+
The library is **never published to npm** (`"private": true`). It is loaded at runtime by the visualizer's AMD loader (RequireJS-compatible). Source files live in `src/`, transpiled AMD output lands in `build/` via `npm run babel-test`.
8+
9+
## Module system — two coexisting styles
10+
11+
| Style | Used in | Import form |
12+
| ------------------------- | ------------------------------------------------------------- | ---------------------------------------- |
13+
| AMD (`define([...], fn)`) | `rest-on-couch/`, `util/`, `chemistry/`, `biology/`, `tiles/` | `define(['dep'], (dep) => { ... })` |
14+
| ESM (`import`/`export`) | `eln/`, newer files | `import Roc from '../rest-on-couch/Roc'` |
15+
16+
When adding code, match the style of the surrounding directory. The Babel plugin `@zakodium/babel-plugin-transform-modules-amd` converts both to AMD for the browser build.
17+
18+
## The `Roc` class (`rest-on-couch/Roc.js`)
19+
20+
Central interface to a rest-on-couch server. Every view that talks to CouchDB instantiates one:
21+
22+
```javascript
23+
const roc = new Roc({ url, database, kind, messages });
24+
// url: base URL of rest-on-couch server (e.g. 'https://couch.example.com')
25+
// database: CouchDB database name (e.g. 'eln')
26+
// kind: default $kind for new entries (optional)
27+
```
28+
29+
Key methods:
30+
31+
| Method | Description |
32+
| --------------------------------- | ----------------------------------------------------------------------------------------- |
33+
| `roc.getUser()` | Returns `{ username, name, groups, roles }` from the session |
34+
| `roc.getUserPrefs(defaultPrefs?)` | Loads per-user JSON blob from `/db/{db}/user/_me` |
35+
| `roc.setUserPrefs(prefs)` | Saves (merges on server) per-user JSON blob |
36+
| `roc.getUserInfo()` | Extended user metadata |
37+
| `roc.view(viewName, options)` | CouchDB view query; options: `key`, `startkey`, `endkey`, `limit`, `mine`, `include_docs` |
38+
| `roc.create(entry)` | Creates a CouchDB entry `{ $id, $content, $kind }` |
39+
| `roc.get(entry)` | Fetches full document |
40+
| `roc.getById(id)` | Fetches by `$id` |
41+
| `roc.update(entry)` | Updates existing document |
42+
| `roc.delete(entry)` | Deletes document |
43+
| `roc.document(uuid, opts)` | Tracked/reactive document; `opts.varName`, `opts.track` |
44+
45+
Authentication is cookie/session-based. All requests use `credentials: 'include'`.
46+
47+
## Saving and loading personal preferences in CouchDB
48+
49+
Three mechanisms exist, from simplest to most powerful:
50+
51+
### 1. Per-user global preferences — `getUserPrefs` / `setUserPrefs`
52+
53+
Simplest option. A single JSON blob per user per database, stored at `/db/{database}/user/_me`.
54+
55+
```javascript
56+
// Load (with fallback defaults)
57+
const prefs = await roc.getUserPrefs({ theme: 'light', pageSize: 25 });
58+
59+
// Save (keys are merged server-side, not replaced)
60+
await roc.setUserPrefs({ theme: 'dark' });
61+
```
62+
63+
Use when: preferences are global to the user across all views of a database.
64+
65+
### 2. Per-view, per-user preferences — `roc.UserViewPrefs`
66+
67+
Stored as a CouchDB entry with `$kind: 'userViewPrefs'`, keyed by `[username, ['userViewPrefs', prefID]]` in the `entryByOwnerAndId` view. `prefID` defaults to the current view's `_id` if omitted.
68+
69+
```javascript
70+
// Available on any Roc instance
71+
const prefs = await roc.UserViewPrefs.get(); // returns $content or undefined
72+
await roc.UserViewPrefs.set({ zoom: 2, filter: 'NMR' });
73+
74+
// Explicit ID (useful when one view manages multiple pref namespaces)
75+
const prefs = await roc.UserViewPrefs.get('myPrefKey');
76+
await roc.UserViewPrefs.set(value, 'myPrefKey');
77+
```
78+
79+
Use when: preferences are specific to a view and should not bleed across views.
80+
81+
### 3. Generic tracked preferences — `preferencesFactory` (`eln/preference.js`)
82+
83+
Creates a CouchDB entry with `$kind: 'viewPreferences'` and ID `${id}_${username}`. Supports reactive updates via `roc.document()` with `track: true`.
84+
85+
```javascript
86+
import { preferencesFactory } from '../eln/preference';
87+
88+
const vp = await preferencesFactory('myView', {
89+
url,
90+
database: 'eln',
91+
initial: [{ key: 'columns', value: ['name', 'date'] }],
92+
});
93+
94+
// The preference object is also cached in the visualizer API under `options.name`
95+
await vp.save();
96+
```
97+
98+
Use when: you need reactive two-way sync between the view's data model and CouchDB.
99+
100+
### 4. localStorage only — `track` (`util/track.js`)
101+
102+
Not CouchDB-backed. Persists in `localStorage` keyed by a cookie name. Supports versioning and default merging. Useful for ephemeral UI state that doesn't need to roam.
103+
104+
```javascript
105+
// AMD
106+
define(['Track'], (track) => {
107+
track(
108+
'massOptions',
109+
{ version: 1, normalize: false },
110+
{ varName: 'massOptions' },
111+
).then((result) => {
112+
/* result is a reactive data object */
113+
});
114+
});
115+
```
116+
117+
## Directory guide
118+
119+
```
120+
rest-on-couch/ Roc.js, UserViewPrefs.js, UserAnalysisResults.js
121+
eln/ ELN-specific helpers (samples, NMR, preference.js, stock, …)
122+
eln/util/ Shared ELN utilities (appendedDragAndDrop, getChartFromMass, …)
123+
util/ Generic helpers: track.js, ModulePrefsManager.js, tips.js, privacy.js
124+
chemistry/ Molecular formula, OCL, NMR renderers
125+
biology/ DNA sequences, alignment helpers
126+
spectra-data/ Spectroscopy data structures
127+
tiles/ UI tile/grid management
128+
on-tabs/ Multi-tab iframe bridge utilities
129+
build/ Babel-transpiled AMD output (generated — do not edit)
130+
```
131+
132+
## Build
133+
134+
```bash
135+
npm run babel-test # transpile src → build/ (AMD)
136+
```
137+
138+
No test suite currently. Linting: `npm run eslint`. Formatting: `npm run prettier-write`.
139+
140+
## Visualizer integration
141+
142+
Views load modules via AMD paths configured in the visualizer. The library exposes helpers that interact with:
143+
144+
- `src/util/api` — visualizer's data/variable API (`API.createData`, `API.getData`, `API.cache`)
145+
- `src/util/versioning` — change tracking (`Versioning.getData().onChange(...)`)
146+
- `IframeBridge` — postMessage bridge between iframe and parent tab (`tab.status`, config passing)

rest-on-couch/UserPreferences.js

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import API from 'src/util/api';
2+
3+
/**
4+
* @typedef {object} RocLike
5+
* @property {(defaultPrefs?: Record<string, unknown>) => Promise<Record<string, unknown>>} getUserPrefs
6+
* @property {(prefs: Record<string, unknown>) => Promise<void>} setUserPrefs
7+
*/
8+
9+
/**
10+
* @typedef {object} UserPreferencesOptions
11+
* @property {string} [varName='userPreferences'] - Visualizer API variable name
12+
* @property {Record<string, unknown>} [defaults={}] - Extra defaults merged on top of the built-in ones
13+
* @property {string[]} [variables] - Preference keys to expose as individual API variables. Defaults to all keys in the merged defaults
14+
*/
15+
16+
/**
17+
* Built-in preference defaults. All keys are automatically exposed as
18+
* individual API variables. Add new properties here as the schema grows.
19+
*/
20+
const DEFAULT_PREFERENCES = {
21+
groupsToAppend: [],
22+
};
23+
24+
/**
25+
* Creates and loads a UserPreferences instance.
26+
*
27+
* Loads preferences from `/db/{database}/user/_me`, exposes them as a
28+
* visualizer API variable, binds each preference key as a separate API
29+
* variable, and auto-saves back to CouchDB whenever the variable changes.
30+
*
31+
* @param {RocLike} roc - Roc instance connected to the CouchDB database
32+
* @param {UserPreferencesOptions} [options]
33+
* @returns {Promise<UserPreferences>}
34+
*
35+
* @example
36+
* import { createUserPreferences } from '../rest-on-couch/UserPreferences';
37+
* const prefs = await createUserPreferences(roc);
38+
*/
39+
export async function createUserPreferences(roc, options = {}) {
40+
const instance = new UserPreferences(roc, options);
41+
await instance._load();
42+
return instance;
43+
}
44+
45+
class UserPreferences {
46+
/**
47+
* @param {RocLike} roc
48+
* @param {UserPreferencesOptions} [options]
49+
*/
50+
constructor(roc, options = {}) {
51+
this.roc = roc;
52+
this.varName = options.varName ?? 'userPreferences';
53+
this.defaults = { ...DEFAULT_PREFERENCES, ...options.defaults };
54+
this.variables = options.variables ?? Object.keys(this.defaults);
55+
}
56+
57+
async _load() {
58+
const stored = (await this.roc.getUserPrefs()) || {};
59+
60+
for (const [key, value] of Object.entries(this.defaults)) {
61+
if (stored[key] === undefined) {
62+
stored[key] = JSON.parse(JSON.stringify(value));
63+
}
64+
}
65+
66+
await API.createData(this.varName, stored);
67+
const prefsVar = API.getVar(this.varName);
68+
69+
for (const key of this.variables) {
70+
API.setVariable(key, prefsVar, [key]);
71+
}
72+
}
73+
74+
/**
75+
* Saves current preferences to CouchDB.
76+
* @returns {Promise<void>}
77+
*/
78+
async save() {
79+
const data = API.getData(this.varName);
80+
if (data) {
81+
await this.roc.setUserPrefs(JSON.parse(JSON.stringify(data)));
82+
}
83+
}
84+
}

0 commit comments

Comments
 (0)