@swell/cli
Version:
Swell's command line interface/utility
174 lines (173 loc) • 7.37 kB
JavaScript
import { HEX24 } from '../apps/object-id.js';
const APPS_MODEL = /^apps\/([^/]+)\/(.+)$/;
/**
* Translate a stored model path into its display form.
*
* apps/<own-hex>/<x> → <x> (own-app prefix is implicit in the slug)
* apps/<other-hex>/<x> → apps/<other-slug>/<x>
* apps/<unknown-hex>/<x> → unchanged (preserve a paste-back form)
* <built-in-model> → unchanged
*
* Stripping `apps/<own>/` keeps the common case readable; the rare collision
* with a same-named built-in (e.g. an app model literally named
* `subscriptions`) is recovered by `expandModelCandidates` at lookup time.
*/
export function normalizeModelForDisplay(model, ownAppId, appSlugById) {
const match = model.match(APPS_MODEL);
if (!match)
return model;
const [, hex, rest] = match;
if (ownAppId && hex === ownAppId)
return rest;
const slug = appSlugById[hex];
return slug ? `apps/${slug}/${rest}` : model;
}
/**
* Produce the column-1 paste-back key for a notification.
*
* System (no app_id): record.id verbatim — already in `com.<model>.<name>` form.
* App with model+name: `app.<slug>.<model-display>.<name>`
* Anything else: record.id (degenerate, no meaningful slug)
*
* Notification identity is `(api, model, name, app_id?)`; collapsing on
* `(app_id, name)` alone caused two `notify_me.back-in-stock` rows
* (different `model`) to render identically.
*/
export function formatNotificationKey(record, appSlugById) {
if (!record.app_id) {
return record.id ?? '-';
}
if (!record.name || !record.model) {
return record.id ?? '-';
}
const slug = appSlugById[record.app_id] ?? record.app_id;
const modelDisplay = normalizeModelForDisplay(record.model, record.app_id, appSlugById);
return `app.${slug}.${modelDisplay}.${record.name}`;
}
/**
* Group a notification record. System rows (no app_id) are not store data —
* they belong to the platform and render under a `system` divider at the top.
*/
export function groupNotificationRecord(record, appSlugById) {
if (!record.app_id) {
return { slug: '<system>', label: 'system', order: 0 };
}
const resolved = appSlugById[record.app_id];
if (!resolved) {
return { slug: '<not resolved>', order: 2 };
}
return { slug: resolved };
}
/**
* Classify a notification identifier. The base `classifyIdentifier` collapses
* every dot-segment after `app.<part>.` into `name`, which loses the model.
* Notifications need a model-aware split, so they bypass the base classifier.
*
* Recognised shapes:
* - 24-char hex → hex (delegate to base GET-by-id)
* - com.<rest> → system_id (canonical platform id)
* - app.<slug>.<model>.<name> → app_full (triplet lookup)
* - app.<slug>.<name> → app_short (3-part legacy; ambiguous)
* - bare <name> → bare (requires --app= scope; ambiguous)
* - anything else → invalid
*
* Multi-segment names (e.g. `unpaid.v2`) only appear in system notifications,
* which arrive via the `com.*` form and never go through `app.*` parsing.
*/
export function parseNotificationKey(input) {
if (HEX24.test(input)) {
return { kind: 'hex', id: input };
}
if (input.startsWith('com.')) {
return { kind: 'system_id', id: input };
}
if (input.startsWith('app.')) {
const parts = input.split('.');
// Reject any post-prefix empty segment (`app..foo`, `app.foo.`, etc.).
// Without this, `app..foo` would classify as `app_short` with an empty
// slug and dispatch a useless `/client/apps` lookup.
const hasEmptySegment = parts.some((p, i) => i > 0 && p.length === 0);
if (hasEmptySegment) {
return { kind: 'invalid', input };
}
if (parts.length === 3) {
return { kind: 'app_short', slug: parts[1], name: parts[2] };
}
if (parts.length >= 4) {
const slug = parts[1];
const name = parts.at(-1);
const model = parts.slice(2, -1).join('.');
return { kind: 'app_full', slug, model, name };
}
return { kind: 'invalid', input };
}
if (/^[\w-]+$/.test(input)) {
return { kind: 'bare', name: input };
}
return { kind: 'invalid', input };
}
/**
* Render the disambiguation error body when a `(app_id, name)` query returns
* multiple notifications. Caller fetches with `limit = displayLimit + 1` so
* we can detect overflow without paging through the whole result set; if
* `results.length > displayLimit`, the surplus rows are dropped and a hint
* line is appended directing the user to the full key form.
*/
export function formatDisambiguationError(results, appSlugById, identifier, displayLimit = 10) {
const truncated = results.length > displayLimit;
const visible = truncated ? results.slice(0, displayLimit) : results;
const lines = visible.map((r) => ` ${formatNotificationKey(r, appSlugById)}`);
if (truncated) {
lines.push(' … (+more candidates; pass the full key)');
}
return `Multiple notifications match '${identifier}'. Use the full key:\n${lines.join('\n')}`;
}
/**
* Build the `template` value used on send records at `/notifications` from a
* manifest record's fields. Three shapes:
*
* System (no app_id): <model>.<name>
* App on a standard model: app_<app_id>.<model>.<name>
* App on a custom (apps/<hex>/x) model: app_<app_id>.apps_<hex>_<x>.<name>
*
* The custom-model branch transforms slashes to underscores in the model
* segment.
*
* V2 system notifications have `name` literally ending in `.v2` (mirroring the
* `id` and the `v2: true` flag), but the send-record `template` strips that
* suffix. App notifications carry `v2: true` without the suffix in `name`, so
* they pass through unchanged.
*
* Returns undefined when `model` or `name` is missing.
*/
export function buildNotificationTemplate(record) {
if (!record.model || !record.name) {
return undefined;
}
const name = record.v2 === true && record.name.endsWith('.v2')
? record.name.slice(0, -'.v2'.length)
: record.name;
const match = record.model.match(APPS_MODEL);
const modelSegment = match ? `apps_${match[1]}_${match[2]}` : record.model;
const prefix = record.app_id ? `app_${record.app_id}.` : '';
return `${prefix}${modelSegment}.${name}`;
}
/**
* Expand a display-form model into the candidate stored values to try at
* lookup time. A bare display token can mean either:
* - the literal built-in model (e.g. `subscriptions`)
* - an own-app model whose `apps/<own>/` prefix was stripped for display
* so we try both. `apps/<slug>/x` is resolved to `apps/<hex>/x`; an already-hex
* `apps/<hex>/x` passes through unchanged.
*/
export async function expandModelCandidates(modelDisplay, appId, resolveAppId) {
const match = modelDisplay.match(APPS_MODEL);
if (match) {
const [, sluglike, rest] = match;
if (HEX24.test(sluglike))
return [modelDisplay];
const hex = await resolveAppId(sluglike);
return [`apps/${hex}/${rest}`];
}
return [modelDisplay, `apps/${appId}/${modelDisplay}`];
}