UNPKG

@swell/cli

Version:

Swell's command line interface/utility

174 lines (173 loc) 7.37 kB
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}`]; }