The data half of the extraction: 8 model directories, 13 utils, the shard
stream catalog and the 27-table schema fragment with its purge.
server/core.js is what makes the port a one-line import change per file rather
than a signature change per function. Ported code requires its dependencies at
file scope -- `const { query } = require('../../core')` -- which runs before
register() has been called and before any ctx exists. So every member is a
stable function that resolves ctx when CALLED, and nothing may be destructured
off ctx at init either, because core is free to hand over a getter.
Two helpers are vendored rather than taken from ctx, and the line between them
is the point. utils/excerpt.js is core's deriveExcerpt -- nine lines of pure
text handling. Core's sanitiser next to it was NOT copied: a second copy of a
security control diverges silently the moment either is fixed. announceLinks.js
vendors legError and articleUrl the same way, but baseUrl could not be: core's
reads APP_BASE_URL, and §2.7 forbids a module reading core's environment, so it
comes off ctx.site.baseUrl.
The schema fragment is core's 27 shard_*/uo_link_* statements, verbs CREATE,
ALTER and UPDATE only, every CREATE TABLE guarded. Two of its tables carry a
foreign key INTO users, which is allowed and is why the replay order matters --
core's schema is in place before this runs. The reverse never occurs and must
not: it would make core unable to boot without a module installed.
One real port bug caught by the integration run, not by tests: the atlas art
map resolved `../../../db/data`, which pointed at core's tree when this file
lived there and points outside server/ now. A path that happens to resolve is
exactly what survives a green suite, because the absent-file branch returns {}
and looks like the normal case.
Co-Authored-By: Claude <noreply@anthropic.com>
124 lines
5.0 KiB
JavaScript
124 lines
5.0 KiB
JavaScript
// ── Town Cryer News gump sync (Protocol 2.1) ───────────────────────────────
|
|
//
|
|
// Keeps the in-game Town Cryer *News* gump in sync with the site's published
|
|
// news posts. Distinct from the scrolling town-crier lines (that's a one-shot
|
|
// announce leg in announceWorker); this is a STATE SYNC — an article stays in the
|
|
// gump while its post is published news, and is pulled when the post is
|
|
// unpublished/deleted/re-categorised.
|
|
//
|
|
// The website is the source of truth. POST /news is idempotent (re-post replaces),
|
|
// so a refresh or a reconnect re-assert is safe. Every call is best-effort and
|
|
// never throws — a sidecar/shard hiccup must never break saving or deleting a
|
|
// post. Reliability comes from reassertAll() on every WS (re)connect
|
|
// (uoLinkSocket.backfill), which re-pushes the current published set silently and
|
|
// closes the gap if an earlier live push failed.
|
|
|
|
const { posts, settings } = require('../core')
|
|
const uoLinkClient = require('./uoLinkClient')
|
|
const { deriveExcerpt } = require('./excerpt')
|
|
const log = require('../core').logger('news-gump')
|
|
|
|
const MAX_TITLE = 120
|
|
const MAX_BODY = 900
|
|
|
|
function baseUrl() {
|
|
return (process.env.APP_BASE_URL || 'http://localhost:5173').replace(/\/+$/, '')
|
|
}
|
|
|
|
function clamp(value, max) {
|
|
const s = String(value == null ? '' : value).replace(/\s+/g, ' ').trim()
|
|
return s.length <= max ? s : `${s.slice(0, max - 1).trimEnd()}…`
|
|
}
|
|
|
|
// A post belongs in the gump exactly when it is published AND in the news category.
|
|
function inGump(post) {
|
|
return Boolean(post && post.published && post.category === 'news')
|
|
}
|
|
|
|
// Optional UO gump image id for news articles (a shard art id), from the
|
|
// `news_gump_image` setting. Omitted → the sidecar uses a neutral scroll.
|
|
async function gumpImage() {
|
|
try {
|
|
const raw = await settings.get('news_gump_image')
|
|
const n = Number(raw)
|
|
return Number.isInteger(n) && n > 0 ? n : undefined
|
|
} catch {
|
|
return undefined
|
|
}
|
|
}
|
|
|
|
// Build the in-game News article from a post. Body is a compact gump-HTML block
|
|
// (title centred + a plain-text excerpt) rather than the post's full rich HTML —
|
|
// the UO gump only supports a small HTML subset, so we keep it predictable. The
|
|
// "more info" URL is the public news list (news posts have no per-post route).
|
|
async function buildArticle(post, { announce = true } = {}) {
|
|
const title = clamp(post.title, MAX_TITLE)
|
|
const excerpt = clamp(post.excerpt || deriveExcerpt(post.body, MAX_BODY) || '', MAX_BODY)
|
|
const body = excerpt ? `<CENTER>${title}</CENTER><BR><BR>${excerpt}` : `<CENTER>${title}</CENTER>`
|
|
return {
|
|
id: String(post.id),
|
|
title,
|
|
body,
|
|
image: await gumpImage(),
|
|
url: `${baseUrl()}/site/news`,
|
|
announce,
|
|
}
|
|
}
|
|
|
|
// Push a post to the gump (only if it belongs there). announce=true has the criers
|
|
// proclaim the title; false is a silent refresh/re-assert.
|
|
async function pushPost(post, { announce = true } = {}) {
|
|
if (!inGump(post)) return { ok: false, skipped: true }
|
|
const res = await uoLinkClient.postNews(await buildArticle(post, { announce }))
|
|
if (!res.ok) log.warn('news gump push failed', { id: post.id, status: res.status, error: res.error })
|
|
return res
|
|
}
|
|
|
|
// Remove a post from the gump. A 404 (not present) is not an error worth noting.
|
|
async function removePost(id) {
|
|
const res = await uoLinkClient.deleteNews(String(id))
|
|
if (!res.ok && res.status !== 404) {
|
|
log.warn('news gump remove failed', { id, status: res.status, error: res.error })
|
|
}
|
|
return res
|
|
}
|
|
|
|
// Reconcile the gump after a post create/update/publish. `transition`
|
|
// ({ wasPublished, wasNews }) tells a fresh publish (announce) from an in-place
|
|
// edit (silent refresh) and catches a post leaving published-news (pull it).
|
|
async function syncPost(post, transition = {}) {
|
|
try {
|
|
if (inGump(post)) {
|
|
const wasInGump = Boolean(transition.wasPublished && transition.wasNews)
|
|
await pushPost(post, { announce: !wasInGump })
|
|
} else if (transition.wasPublished && transition.wasNews) {
|
|
await removePost(post.id)
|
|
}
|
|
} catch (err) {
|
|
log.warn('news gump sync failed', { id: post && post.id, message: err.message })
|
|
}
|
|
}
|
|
|
|
// Re-push every currently-published news post, silently — run on each WS
|
|
// (re)connect to reconcile the gump to our source of truth (also recovers any
|
|
// article whose original live push failed). Best-effort; never throws.
|
|
async function reassertAll() {
|
|
try {
|
|
const list = await posts.listAll('news')
|
|
const published = (list || []).filter((p) => p.published)
|
|
let pushed = 0
|
|
for (const p of published) {
|
|
const full = await posts.getById(p.id) // list projection may omit the body
|
|
if (full) {
|
|
await pushPost(full, { announce: false })
|
|
pushed += 1
|
|
}
|
|
}
|
|
if (pushed) log.info('re-asserted news gump articles', { count: pushed })
|
|
} catch (err) {
|
|
log.warn('news gump reassert failed', { message: err.message })
|
|
}
|
|
}
|
|
|
|
module.exports = { inGump, buildArticle, pushPost, removePost, syncPost, reassertAll }
|