@anthusai/papyrus 1.10.0 → 1.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/amplify/data/resource.js +70 -3
- package/amplify/data/resource.ts +75 -3
- package/amplify/data/schema.js +67 -0
- package/amplify/data/schema.ts +72 -0
- package/amplify/functions/category-action/handler.ts +190 -20
- package/amplify/functions/console-chat-responder/src/main.rs +1 -1
- package/amplify/functions/content-actions/handler.py +23 -0
- package/amplify/functions/content-actions/requirements.txt +1 -1
- package/amplify/functions/content-actions/resource.js +1 -1
- package/amplify/functions/content-actions/resource.ts +1 -1
- package/amplify/functions/renditions/requirements.txt +1 -1
- package/amplify/functions/shared/curation-delegation.ts +67 -0
- package/amplify/functions/shared/reference-curation-metadata.ts +20 -0
- package/amplify/functions/shared/slack-events.ts +2 -2
- package/amplify/publication/renditions-function.js +1 -1
- package/amplify/publication/renditions-function.ts +1 -1
- package/app/api/local-preview/derive/route.ts +28 -0
- package/app/api/local-preview/media/route.ts +25 -0
- package/app/globals.css +28 -28
- package/app/layout.tsx +8 -5
- package/app/newsroom/[section]/[...selection]/page.tsx +0 -1
- package/app/newsroom/[section]/page.tsx +0 -1
- package/app/newsroom/insights/[threadId]/page.tsx +5 -16
- package/app/newsroom/insights/page.tsx +4 -12
- package/components/administration-delegations-panel.tsx +204 -0
- package/components/board-card-excerpts.tsx +45 -0
- package/components/curation-delegation-client.ts +81 -0
- package/components/markus-ir-preview.tsx +47 -2
- package/components/news-desk-page.tsx +0 -1
- package/components/news-desk-taxonomy-client.ts +32 -220
- package/components/newsroom-article-editor.tsx +135 -39
- package/components/newsroom-assignment-detail.tsx +35 -1
- package/components/newsroom-board-view.tsx +29 -7
- package/components/newsroom-newsprint-board.css +9 -2
- package/components/newsroom-ops-overview.tsx +27 -5
- package/components/newsroom-organizer-shell.tsx +25 -7
- package/components/newsroom-organizer.css +218 -4
- package/components/newsroom-references-view.tsx +56 -21
- package/components/priming-proof-page.tsx +17 -8
- package/components/prospectus-account-chooser.tsx +98 -0
- package/components/reference-cyclotron-status.tsx +5 -5
- package/components/reference-cyclotron-suggestion.tsx +143 -0
- package/components/reference-live-agreement.tsx +62 -0
- package/components/story-reporting-trail-client.ts +91 -0
- package/components/story-reporting-trail.tsx +115 -0
- package/components/topic-steering-workspace.tsx +203 -674
- package/components/ui/sheet.tsx +1 -0
- package/components/wiki-concepts-view.tsx +153 -0
- package/components/wiki-index-client.ts +51 -0
- package/lib/content-actions-client.ts +3 -0
- package/lib/content-actions-demo.ts +55 -3
- package/lib/content-item-lifecycle.ts +4 -2
- package/lib/content-source-context.ts +9 -1
- package/lib/curation-delegation-helpers.ts +59 -0
- package/lib/cyclotron-suggestions.ts +263 -0
- package/lib/front-matter-fields.ts +42 -0
- package/lib/graphql-content-repository.ts +42 -7
- package/lib/local-preview-seed.ts +174 -3
- package/lib/local-preview-tenant.ts +27 -0
- package/lib/newsroom-articles.ts +2 -0
- package/lib/newsroom-board.ts +4 -0
- package/lib/newsroom-forum-routes.ts +2 -43
- package/lib/newsroom-index-filters.ts +3 -40
- package/lib/newsroom-media-backend.ts +5 -0
- package/lib/newsroom-nav.ts +1 -3
- package/lib/newsroom-organizer-nav.ts +5 -12
- package/lib/newsroom-section-routing.ts +1 -1
- package/lib/newsroom-sections.ts +12 -0
- package/lib/papyrus-web-locations.ts +3 -24
- package/lib/priming-proof-report.ts +1 -0
- package/lib/prospectus-tenant.ts +22 -0
- package/lib/relevance-decisions.ts +29 -0
- package/lib/semantic-graph.ts +5 -5
- package/lib/site-brand.ts +9 -0
- package/lib/story-reporting-trail.ts +106 -0
- package/lib/tenant-keys.ts +32 -0
- package/lib/wiki.ts +56 -0
- package/package.json +21 -1
- package/routes.manifest.json +23 -7
package/components/ui/sheet.tsx
CHANGED
|
@@ -45,6 +45,7 @@ function SheetContent({
|
|
|
45
45
|
<DialogPrimitive.Backdrop
|
|
46
46
|
className={cn(
|
|
47
47
|
"fixed inset-0 z-50 bg-black/40 data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
|
48
|
+
typeof className === "string" && className.includes("md:hidden") && "md:hidden",
|
|
48
49
|
backdropClassName,
|
|
49
50
|
)}
|
|
50
51
|
/>
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import Link from "next/link";
|
|
4
|
+
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
|
5
|
+
import type { SemanticNodeRecord } from "../lib/category-repository";
|
|
6
|
+
import { isLocalPreviewEnabled } from "../lib/local-preview";
|
|
7
|
+
import { getNewsroomNavHref } from "../lib/newsroom-nav";
|
|
8
|
+
import type { createSemanticGraphSnapshot } from "../lib/semantic-graph";
|
|
9
|
+
import {
|
|
10
|
+
conceptEditedAt, conceptId, conceptTitle, conceptWikiPath, conceptsByTitle, recentlyEditedConcepts, resolveConceptLink,
|
|
11
|
+
} from "../lib/wiki";
|
|
12
|
+
import { MarkusIrPreview } from "./markus-ir-preview";
|
|
13
|
+
import { createWikiIndexPage, loadWikiIndexPage, type WikiIndexPage } from "./wiki-index-client";
|
|
14
|
+
|
|
15
|
+
type SemanticGraph = ReturnType<typeof createSemanticGraphSnapshot>;
|
|
16
|
+
|
|
17
|
+
const editedFormat = new Intl.DateTimeFormat(undefined, { dateStyle: "medium" });
|
|
18
|
+
|
|
19
|
+
function formatEdited(value: string): string {
|
|
20
|
+
const parsed = new Date(value);
|
|
21
|
+
return value && !Number.isNaN(parsed.getTime()) ? editedFormat.format(parsed) : "";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function kindLabel(kind: string): string {
|
|
25
|
+
return kind.replace(/_/g, " ");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function ConceptLink({ children, demo, node, onOpen }: { children?: ReactNode; demo: boolean; node: SemanticNodeRecord; onOpen: (lineageId: string) => void }) {
|
|
29
|
+
return <a className="wiki-link" data-wiki-concept-link={conceptId(node)} href={getNewsroomNavHref(conceptWikiPath(node), demo)} onClick={event => { if (event.metaKey || event.ctrlKey || event.shiftKey) return; event.preventDefault(); onOpen(conceptId(node)); }}>{children ?? conceptTitle(node)}</a>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function ConceptRows({ demo, nodes, onOpen, showEdited }: { demo: boolean; nodes: SemanticNodeRecord[]; onOpen: (lineageId: string) => void; showEdited?: boolean }) {
|
|
33
|
+
if (!nodes.length) return <p className="wiki-empty">No concepts to show.</p>;
|
|
34
|
+
return <ul className="wiki-concept-list">
|
|
35
|
+
{nodes.map(node => <li key={conceptId(node)}>
|
|
36
|
+
<ConceptLink demo={demo} node={node} onOpen={onOpen} />
|
|
37
|
+
<span className="wiki-chip">{kindLabel(node.nodeKind)}</span>
|
|
38
|
+
{showEdited && conceptEditedAt(node) && <span className="wiki-meta">edited {formatEdited(conceptEditedAt(node))}</span>}
|
|
39
|
+
</li>)}
|
|
40
|
+
</ul>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function WikiIndexPageView({ demo, nodes, onOpen }: { demo: boolean; nodes: SemanticNodeRecord[]; onOpen: (lineageId: string) => void }) {
|
|
44
|
+
const [page, setPage] = useState<WikiIndexPage | null>(null);
|
|
45
|
+
const [error, setError] = useState<string | null>(null);
|
|
46
|
+
const [saving, setSaving] = useState(false);
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
let cancelled = false;
|
|
49
|
+
loadWikiIndexPage(demo || isLocalPreviewEnabled()).then(loaded => { if (!cancelled) setPage(loaded); }).catch((reason: unknown) => { if (!cancelled) setError(reason instanceof Error ? reason.message : "Could not load the wiki index."); });
|
|
50
|
+
return () => { cancelled = true; };
|
|
51
|
+
}, [demo]);
|
|
52
|
+
const dynamicDirectives = useMemo(() => ({
|
|
53
|
+
"recent-concepts": (attributes: Record<string, unknown>) => <ConceptRows demo={demo} nodes={recentlyEditedConcepts(nodes, { kind: typeof attributes.kind === "string" ? attributes.kind : null, limit: Number(attributes.limit) || 10 })} onOpen={onOpen} showEdited />,
|
|
54
|
+
"concept-list": (attributes: Record<string, unknown>) => <ConceptRows demo={demo} nodes={conceptsByTitle(nodes, { kind: typeof attributes.kind === "string" ? attributes.kind : null, limit: Number(attributes.limit) || null })} onOpen={onOpen} />,
|
|
55
|
+
"concept-count": (attributes: Record<string, unknown>) => <strong>{nodes.filter(node => !attributes.kind || node.nodeKind === attributes.kind).length}</strong>,
|
|
56
|
+
}), [demo, nodes, onOpen]);
|
|
57
|
+
const renderLink = (href: string, children: ReactNode) => {
|
|
58
|
+
const node = resolveConceptLink(href, nodes);
|
|
59
|
+
return node ? <ConceptLink demo={demo} node={node} onOpen={onOpen}>{children}</ConceptLink> : null;
|
|
60
|
+
};
|
|
61
|
+
if (error) return <p className="wiki-empty" role="alert">{error}</p>;
|
|
62
|
+
if (!page) return <p className="wiki-empty">Loading the wiki…</p>;
|
|
63
|
+
return <article className="wiki-page" data-wiki-index>
|
|
64
|
+
<div className="wiki-page-tools">
|
|
65
|
+
{page.itemId
|
|
66
|
+
? <Link className="wiki-tool-link" href={getNewsroomNavHref(`/newsroom/articles/${encodeURIComponent(page.itemId)}`, demo)}>Edit this page</Link>
|
|
67
|
+
: <button className="wiki-tool-link" disabled={saving} onClick={() => { setSaving(true); createWikiIndexPage(demo || isLocalPreviewEnabled()).then(setPage).catch((reason: unknown) => setError(reason instanceof Error ? reason.message : "Could not save the index.")).finally(() => setSaving(false)); }} type="button">{saving ? "Saving…" : "Save this page so it can be edited"}</button>}
|
|
68
|
+
</div>
|
|
69
|
+
<MarkusIrPreview bodyIr={page.bodyIr} dynamicDirectives={dynamicDirectives} renderLink={renderLink} />
|
|
70
|
+
</article>;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function ConceptPageView({ demo, graph, node, nodes, onOpen }: { demo: boolean; graph: SemanticGraph; node: SemanticNodeRecord; nodes: SemanticNodeRecord[]; onOpen: (lineageId: string) => void }) {
|
|
74
|
+
const lineage = conceptId(node);
|
|
75
|
+
const groups = graph.neighbors("semanticNode", lineage);
|
|
76
|
+
const references = graph.referencesForSemanticNode(lineage);
|
|
77
|
+
const byLineage = new Map(nodes.map(candidate => [conceptId(candidate), candidate]));
|
|
78
|
+
const aliases = (node.aliases ?? []).filter(Boolean) as string[];
|
|
79
|
+
return <article className="wiki-page" data-wiki-concept={lineage}>
|
|
80
|
+
<h1>{conceptTitle(node)}</h1>
|
|
81
|
+
<p className="wiki-meta"><span className="wiki-chip">{kindLabel(node.nodeKind)}</span> {node.status !== "current" && <span className="wiki-chip">{node.status}</span>} {conceptEditedAt(node) && <>Last edited {formatEdited(conceptEditedAt(node))}</>}{node.versionCreatedBy ? ` by ${node.versionCreatedBy}` : ""}</p>
|
|
82
|
+
{aliases.length > 0 && <p className="wiki-meta">Also known as {aliases.join(", ")}</p>}
|
|
83
|
+
{node.description ? node.description.split(/\n{2,}/).map((paragraph, index) => <p key={index}>{paragraph}</p>) : <p className="wiki-empty">This concept has no description yet.</p>}
|
|
84
|
+
<section data-wiki-section="connections">
|
|
85
|
+
<h2>Connections</h2>
|
|
86
|
+
{groups.length ? groups.map(group => <div className="wiki-connection-group" key={`${group.direction}-${group.predicate}`}>
|
|
87
|
+
<h3>{group.label}</h3>
|
|
88
|
+
<ul className="wiki-concept-list">
|
|
89
|
+
{group.objects.map(object => {
|
|
90
|
+
const target = object.kind === "semanticNode" ? byLineage.get(object.lineageId) : undefined;
|
|
91
|
+
return <li key={`${object.kind}-${object.lineageId}`}>{target ? <ConceptLink demo={demo} node={target} onOpen={onOpen} /> : <span>{object.label}</span>}<span className="wiki-chip">{target ? kindLabel(target.nodeKind) : object.kind}</span></li>;
|
|
92
|
+
})}
|
|
93
|
+
</ul>
|
|
94
|
+
</div>) : <p className="wiki-empty">No connections recorded.</p>}
|
|
95
|
+
</section>
|
|
96
|
+
<section data-wiki-section="references">
|
|
97
|
+
<h2>References</h2>
|
|
98
|
+
{references.length ? <ul className="wiki-concept-list">
|
|
99
|
+
{references.map(reference => <li key={reference.lineageId}><Link href={getNewsroomNavHref(`/newsroom/references/${encodeURIComponent(reference.lineageId)}`, demo)}>{reference.label}</Link></li>)}
|
|
100
|
+
</ul> : <p className="wiki-empty">No references recorded.</p>}
|
|
101
|
+
</section>
|
|
102
|
+
</article>;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The concept wiki: the front page is a content item whose Markus body can hold live components
|
|
107
|
+
* (recently edited concepts, concept lists), and every concept in the knowledge graph is a page
|
|
108
|
+
* linked to the concepts and references it connects to.
|
|
109
|
+
*/
|
|
110
|
+
export function WikiConceptsView({ demo = false, graph, initialNodeLineageId, nodes }: { demo?: boolean; graph: SemanticGraph; initialNodeLineageId?: string | null; nodes: SemanticNodeRecord[] }) {
|
|
111
|
+
const [selected, setSelected] = useState<string | null>(initialNodeLineageId ?? null);
|
|
112
|
+
const [search, setSearch] = useState("");
|
|
113
|
+
const [kind, setKind] = useState("");
|
|
114
|
+
useEffect(() => { setSelected(initialNodeLineageId ?? null); }, [initialNodeLineageId]);
|
|
115
|
+
useEffect(() => {
|
|
116
|
+
const onPop = () => {
|
|
117
|
+
const match = window.location.pathname.match(/\/concepts\/([^/?#]+)/);
|
|
118
|
+
setSelected(match ? decodeURIComponent(match[1]) : null);
|
|
119
|
+
};
|
|
120
|
+
window.addEventListener("popstate", onPop);
|
|
121
|
+
return () => window.removeEventListener("popstate", onPop);
|
|
122
|
+
}, []);
|
|
123
|
+
const open = (lineageId: string | null) => {
|
|
124
|
+
setSelected(lineageId);
|
|
125
|
+
const target = getNewsroomNavHref(lineageId ? `/newsroom/concepts/${encodeURIComponent(lineageId)}` : "/newsroom/concepts", demo);
|
|
126
|
+
if (`${window.location.pathname}${window.location.search}` !== target) window.history.pushState(null, "", target);
|
|
127
|
+
};
|
|
128
|
+
const node = selected ? nodes.find(candidate => conceptId(candidate) === selected || candidate.id === selected) ?? null : null;
|
|
129
|
+
const kinds = useMemo(() => Array.from(new Set(nodes.map(candidate => candidate.nodeKind))).sort(), [nodes]);
|
|
130
|
+
const matches = useMemo(() => {
|
|
131
|
+
const needle = search.trim().toLowerCase();
|
|
132
|
+
return conceptsByTitle(nodes, { kind: kind || null }).filter(candidate => !needle || conceptTitle(candidate).toLowerCase().includes(needle) || (candidate.aliases ?? []).some(alias => alias?.toLowerCase().includes(needle)));
|
|
133
|
+
}, [kind, nodes, search]);
|
|
134
|
+
return <div className="wiki" data-wiki>
|
|
135
|
+
<nav aria-label="Wiki" className="wiki-sidebar">
|
|
136
|
+
<button className="wiki-home" data-wiki-home aria-current={node ? undefined : "page"} onClick={() => open(null)} type="button">Wiki home</button>
|
|
137
|
+
<input aria-label="Search concepts" className="wiki-search" onChange={event => setSearch(event.target.value)} placeholder="Search concepts" value={search} />
|
|
138
|
+
{kinds.length > 1 && <select aria-label="Concept kind" className="wiki-kind" onChange={event => setKind(event.target.value)} value={kind}>
|
|
139
|
+
<option value="">All kinds</option>
|
|
140
|
+
{kinds.map(option => <option key={option} value={option}>{kindLabel(option)}</option>)}
|
|
141
|
+
</select>}
|
|
142
|
+
<ul className="wiki-sidebar-list">
|
|
143
|
+
{matches.map(candidate => <li key={conceptId(candidate)}><a aria-current={node && conceptId(node) === conceptId(candidate) ? "page" : undefined} href={getNewsroomNavHref(conceptWikiPath(candidate), demo)} onClick={event => { if (event.metaKey || event.ctrlKey || event.shiftKey) return; event.preventDefault(); open(conceptId(candidate)); }}>{conceptTitle(candidate)}</a></li>)}
|
|
144
|
+
</ul>
|
|
145
|
+
</nav>
|
|
146
|
+
<div className="wiki-main">
|
|
147
|
+
{node
|
|
148
|
+
? <ConceptPageView demo={demo} graph={graph} node={node} nodes={nodes} onOpen={open} />
|
|
149
|
+
: selected ? <p className="wiki-empty" role="alert">That concept page was not found.</p>
|
|
150
|
+
: <WikiIndexPageView demo={demo} nodes={nodes} onOpen={open} />}
|
|
151
|
+
</div>
|
|
152
|
+
</div>;
|
|
153
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { generateClient } from "aws-amplify/data";
|
|
4
|
+
import type { Schema } from "../amplify/data/resource";
|
|
5
|
+
import { resolveArticlesBackend } from "../lib/newsroom-articles";
|
|
6
|
+
import { isLocalPreviewEnabled } from "../lib/local-preview";
|
|
7
|
+
import { DEFAULT_WIKI_INDEX_MARKUS, WIKI_INDEX_SLUG, WIKI_PAGE_ITEM_TYPE } from "../lib/wiki";
|
|
8
|
+
import { configureAmplifyClient } from "./amplify-client-provider";
|
|
9
|
+
|
|
10
|
+
export type WikiIndexPage = {
|
|
11
|
+
/** The id of the stored content item, or null while the wiki still shows the built-in starter content. */
|
|
12
|
+
itemId: string | null;
|
|
13
|
+
bodyMarkus: string;
|
|
14
|
+
bodyIr: unknown;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
type GraphqlResponse = { data?: { itemBySlug?: { items?: Record<string, unknown>[] | null } | null } | null; errors?: { message?: string }[] | null };
|
|
18
|
+
|
|
19
|
+
async function storedIndexRow(preview: boolean): Promise<Record<string, unknown> | null> {
|
|
20
|
+
if (preview || isLocalPreviewEnabled()) return null;
|
|
21
|
+
configureAmplifyClient();
|
|
22
|
+
const client = generateClient<Schema>() as unknown as { graphql: (options: Record<string, unknown>) => Promise<GraphqlResponse> };
|
|
23
|
+
const response = await client.graphql({
|
|
24
|
+
query: "query ($slug: String!) { itemBySlug(slug: $slug) { items { id versionNumber versionState bodyMarkus bodyIr } } }",
|
|
25
|
+
variables: { slug: WIKI_INDEX_SLUG },
|
|
26
|
+
authMode: "userPool",
|
|
27
|
+
});
|
|
28
|
+
if (response.errors?.length) throw new Error(response.errors.map(error => error.message ?? "GraphQL error").join("; "));
|
|
29
|
+
const rows = (response.data?.itemBySlug?.items ?? []).filter(Boolean) as Record<string, unknown>[];
|
|
30
|
+
const ranked = rows.sort((left, right) => Number(right.versionNumber ?? 0) - Number(left.versionNumber ?? 0));
|
|
31
|
+
return ranked.find(row => row.versionState === "current") ?? ranked[0] ?? null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function derive(bodyMarkus: string, preview: boolean): Promise<unknown> {
|
|
35
|
+
const result = await resolveArticlesBackend(preview).actions.deriveMarkus({ frontMatterYaml: null, bodyMarkus, includeIr: true });
|
|
36
|
+
return result.bodyIr ?? null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Loads the wiki's front page content item, falling back to the starter content until one is saved. */
|
|
40
|
+
export async function loadWikiIndexPage(preview: boolean): Promise<WikiIndexPage> {
|
|
41
|
+
const row = await storedIndexRow(preview);
|
|
42
|
+
const bodyMarkus = typeof row?.bodyMarkus === "string" && row.bodyMarkus.trim() ? row.bodyMarkus : DEFAULT_WIKI_INDEX_MARKUS;
|
|
43
|
+
const storedIr = row?.bodyIr;
|
|
44
|
+
return { itemId: row ? String(row.id) : null, bodyMarkus, bodyIr: storedIr ?? await derive(bodyMarkus, preview) };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Saves the starter index as the wiki-index content item so editors can change it like any other page. */
|
|
48
|
+
export async function createWikiIndexPage(preview: boolean): Promise<WikiIndexPage> {
|
|
49
|
+
const saved = await resolveArticlesBackend(preview).actions.saveItemDraft({ id: null, type: WIKI_PAGE_ITEM_TYPE, slug: WIKI_INDEX_SLUG, section: null, frontMatterYaml: `title: Concept wiki\n`, bodyMarkus: DEFAULT_WIKI_INDEX_MARKUS, aliases: [], expectedContentHash: null });
|
|
50
|
+
return { itemId: saved.item.id, bodyMarkus: DEFAULT_WIKI_INDEX_MARKUS, bodyIr: await derive(DEFAULT_WIKI_INDEX_MARKUS, preview) };
|
|
51
|
+
}
|
|
@@ -13,6 +13,9 @@ export type SaveItemDraftInput = {
|
|
|
13
13
|
bodyMarkus: string;
|
|
14
14
|
aliases: string[];
|
|
15
15
|
expectedContentHash: string | null;
|
|
16
|
+
/** Records a new draft version of the story instead of correcting the current one in place. */
|
|
17
|
+
newVersion?: boolean;
|
|
18
|
+
changeReason?: string | null;
|
|
16
19
|
};
|
|
17
20
|
|
|
18
21
|
export type DeriveMarkusResult = { ok: true; bodyIrBytes: number; bodyIr?: unknown; errors: [] };
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { ContentActionFailure } from "./content-actions-client";
|
|
2
|
+
import { isLocalPreviewEnabled } from "./local-preview";
|
|
2
3
|
import type {
|
|
3
4
|
DeriveMarkusInput,
|
|
4
5
|
DeriveMarkusResult,
|
|
@@ -12,7 +13,7 @@ import {
|
|
|
12
13
|
demoContentHash,
|
|
13
14
|
type DemoArticleRecord,
|
|
14
15
|
} from "./newsroom-demo-articles";
|
|
15
|
-
import { createLocalPreviewEditableArticle } from "./local-preview-seed";
|
|
16
|
+
import { createLocalPreviewEditableArticle, lineageForPreviewVersion, recordLocalPreviewDesk, recordLocalPreviewVersion } from "./local-preview-seed";
|
|
16
17
|
import { sourceLineOffset, type ArticlesBackend, type EditableArticle, type NewsroomArticleRow } from "./newsroom-articles";
|
|
17
18
|
|
|
18
19
|
function titleFromYaml(frontMatterYaml: string | null, fallback: string): string {
|
|
@@ -30,6 +31,18 @@ function demoAttribute(attributes: string, name: string): string {
|
|
|
30
31
|
return match ? match[1] : "";
|
|
31
32
|
}
|
|
32
33
|
|
|
34
|
+
async function deriveThroughPreviewMarkusEngine(input: DeriveMarkusInput): Promise<DeriveMarkusResult | null> {
|
|
35
|
+
try {
|
|
36
|
+
const response = await fetch("/api/local-preview/derive", { method: "POST", body: JSON.stringify({ frontMatterYaml: input.frontMatterYaml, bodyMarkus: input.bodyMarkus }) });
|
|
37
|
+
if (!response.ok) return null;
|
|
38
|
+
const result = await response.json() as DeriveMarkusResult & { error?: string };
|
|
39
|
+
if (result.error) return null;
|
|
40
|
+
return input.includeIr ? result : { ...result, bodyIr: undefined };
|
|
41
|
+
} catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
33
46
|
function demoEnvelope(bodyMarkus: string) {
|
|
34
47
|
const images: Record<string, Record<string, unknown>> = {};
|
|
35
48
|
const children = bodyMarkus
|
|
@@ -74,6 +87,10 @@ export function createDemoArticlesBackend(): ArticlesBackend {
|
|
|
74
87
|
return {
|
|
75
88
|
actions: {
|
|
76
89
|
async deriveMarkus(input: DeriveMarkusInput): Promise<DeriveMarkusResult> {
|
|
90
|
+
if (isLocalPreviewEnabled()) {
|
|
91
|
+
const derived = await deriveThroughPreviewMarkusEngine(input);
|
|
92
|
+
if (derived) return derived;
|
|
93
|
+
}
|
|
77
94
|
const lines = input.bodyMarkus.split("\n");
|
|
78
95
|
const invalidIndex = lines.findIndex((line) => line.includes(":::nope"));
|
|
79
96
|
if (invalidIndex >= 0) {
|
|
@@ -91,6 +108,28 @@ export function createDemoArticlesBackend(): ArticlesBackend {
|
|
|
91
108
|
},
|
|
92
109
|
async saveItemDraft(input: SaveItemDraftInput): Promise<SaveItemDraftResult> {
|
|
93
110
|
const existing = input.id ? requireRecord(input.id) : null;
|
|
111
|
+
if (input.newVersion && existing) {
|
|
112
|
+
const lineageId = createLocalPreviewEditableArticle(existing.id)?.lineageId ?? existing.id;
|
|
113
|
+
const versionNumber = (existing.versionNumber ?? 1) + 1;
|
|
114
|
+
const id = `${lineageId}-v${versionNumber}`;
|
|
115
|
+
const frontMatterYaml = input.frontMatterYaml ?? "";
|
|
116
|
+
const record: DemoArticleRecord = {
|
|
117
|
+
...existing,
|
|
118
|
+
id,
|
|
119
|
+
slug: existing.slug,
|
|
120
|
+
section: input.section,
|
|
121
|
+
title: titleFromYaml(input.frontMatterYaml, existing.slug),
|
|
122
|
+
status: "draft",
|
|
123
|
+
frontMatterYaml,
|
|
124
|
+
bodyMarkus: input.bodyMarkus,
|
|
125
|
+
contentHash: demoContentHash(frontMatterYaml, input.bodyMarkus),
|
|
126
|
+
versionNumber,
|
|
127
|
+
updatedAt: new Date().toISOString(),
|
|
128
|
+
};
|
|
129
|
+
records.set(id, record);
|
|
130
|
+
recordLocalPreviewVersion(lineageId, { id, versionNumber, versionState: "current", status: "draft", stage: null, headline: record.title, changeReason: input.changeReason ?? null, versionCreatedBy: "You", versionCreatedAt: record.updatedAt, updatedAt: record.updatedAt });
|
|
131
|
+
return { ok: true, item: { id, contentHash: record.contentHash, status: "draft", slug: record.slug, versionNumber } };
|
|
132
|
+
}
|
|
94
133
|
if (existing && input.expectedContentHash !== null && input.expectedContentHash !== existing.contentHash) {
|
|
95
134
|
throw new ContentActionFailure([
|
|
96
135
|
{ code: "conflict", message: "The item changed since it was loaded.", line: null },
|
|
@@ -120,6 +159,7 @@ export function createDemoArticlesBackend(): ArticlesBackend {
|
|
|
120
159
|
updatedAt: new Date().toISOString(),
|
|
121
160
|
};
|
|
122
161
|
records.set(id, record);
|
|
162
|
+
recordLocalPreviewDesk(id, record.section);
|
|
123
163
|
return {
|
|
124
164
|
ok: true,
|
|
125
165
|
item: {
|
|
@@ -172,11 +212,23 @@ export function createDemoArticlesBackend(): ArticlesBackend {
|
|
|
172
212
|
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
|
|
173
213
|
},
|
|
174
214
|
async loadArticle(id: string): Promise<EditableArticle> {
|
|
175
|
-
|
|
176
|
-
|
|
215
|
+
if (!records.has(id)) {
|
|
216
|
+
const previewRecord = createLocalPreviewEditableArticle(id);
|
|
217
|
+
if (previewRecord) {
|
|
218
|
+
records.set(id, {
|
|
219
|
+
...previewRecord,
|
|
220
|
+
title: titleFromYaml(previewRecord.frontMatterYaml, previewRecord.slug),
|
|
221
|
+
status: previewRecord.status === "published" ? "published" : "draft",
|
|
222
|
+
contentHash: demoContentHash(previewRecord.frontMatterYaml, previewRecord.bodyMarkus),
|
|
223
|
+
publishedContentHash: previewRecord.status === "published" ? demoContentHash(previewRecord.frontMatterYaml, previewRecord.bodyMarkus) : null,
|
|
224
|
+
updatedAt: new Date().toISOString(),
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
}
|
|
177
228
|
const record = requireRecord(id);
|
|
178
229
|
return {
|
|
179
230
|
id: record.id,
|
|
231
|
+
lineageId: createLocalPreviewEditableArticle(record.id)?.lineageId ?? lineageForPreviewVersion(record.id),
|
|
180
232
|
type: record.type,
|
|
181
233
|
section: record.section,
|
|
182
234
|
slug: record.slug,
|
|
@@ -28,9 +28,9 @@ export const DEFAULT_CONTENT_ITEM_STAGE_DEFINITIONS: readonly ContentItemStageDe
|
|
|
28
28
|
{ key: "copywriting", label: "Copywriting" },
|
|
29
29
|
{ key: "illustration", label: "Illustration" },
|
|
30
30
|
{ key: "typesetting", label: "Typesetting" },
|
|
31
|
-
{ key: "published", label: "Published" },
|
|
32
|
-
{ key: "held", label: "Held" },
|
|
33
31
|
{ key: "spiked", label: "Spiked", collapsedUntilOpened: true },
|
|
32
|
+
{ key: "held", label: "Held" },
|
|
33
|
+
{ key: "published", label: "Published" },
|
|
34
34
|
];
|
|
35
35
|
|
|
36
36
|
export function contentItemStageKeys(stageDefinitions: readonly ContentItemStageDefinition[]): string[] {
|
|
@@ -68,6 +68,8 @@ export type ContentItemVersionRecord = {
|
|
|
68
68
|
status: string;
|
|
69
69
|
title?: string | null;
|
|
70
70
|
headline?: string | null;
|
|
71
|
+
deck?: string | null;
|
|
72
|
+
metadata?: unknown;
|
|
71
73
|
slug: string;
|
|
72
74
|
section?: string | null;
|
|
73
75
|
updatedAt?: string | null;
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
2
|
import { getContentSource, type ContentSource } from "./site-env";
|
|
3
|
+
import { tenantIdFromEnvironment } from "./tenant-keys";
|
|
3
4
|
|
|
4
5
|
export type ContentSourceContext = {
|
|
5
6
|
source: ContentSource;
|
|
6
7
|
/** Request-scoped data client (drafts mode: carries the visitor's session cookies). */
|
|
7
8
|
client?: unknown;
|
|
9
|
+
/** Tenant of the selected Prospectus account; falls back to PAPYRUS_TENANT_ID. */
|
|
10
|
+
tenantId?: string;
|
|
8
11
|
};
|
|
9
12
|
|
|
10
13
|
const contentSourceStorage = new AsyncLocalStorage<ContentSourceContext>();
|
|
@@ -13,8 +16,13 @@ export function runWithContentSource<T>(
|
|
|
13
16
|
source: ContentSource,
|
|
14
17
|
operation: () => Promise<T>,
|
|
15
18
|
client?: unknown,
|
|
19
|
+
tenantId?: string,
|
|
16
20
|
): Promise<T> {
|
|
17
|
-
return contentSourceStorage.run({ source, client }, operation);
|
|
21
|
+
return contentSourceStorage.run({ source, client, tenantId }, operation);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function currentTenantId(): string | undefined {
|
|
25
|
+
return contentSourceStorage.getStore()?.tenantId ?? tenantIdFromEnvironment();
|
|
18
26
|
}
|
|
19
27
|
|
|
20
28
|
export function currentRequestClient<T>(): T | undefined {
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
export const DEFAULT_DELEGATION_HOURS = 8;
|
|
2
|
+
export const MINIMUM_DELEGATION_HOURS = 1;
|
|
3
|
+
export const MAXIMUM_DELEGATION_HOURS = 72;
|
|
4
|
+
|
|
5
|
+
export type CurationDelegationRecord = {
|
|
6
|
+
delegationId: string;
|
|
7
|
+
agentLabel: string;
|
|
8
|
+
grantorLabel: string | null;
|
|
9
|
+
expiresAt: string;
|
|
10
|
+
revokedAt: string | null;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type DelegationInputValidation =
|
|
14
|
+
| { ok: true; agentLabel: string; hours: number }
|
|
15
|
+
| { ok: false; message: string };
|
|
16
|
+
|
|
17
|
+
export function validateDelegationInput(agentLabelText: string, hoursText: string): DelegationInputValidation {
|
|
18
|
+
const agentLabel = agentLabelText.trim();
|
|
19
|
+
if (!agentLabel) return { ok: false, message: "Enter the name of the agent you are trusting." };
|
|
20
|
+
if (/\s/.test(agentLabel)) return { ok: false, message: "The agent name cannot contain spaces. Use the name the agent passes as --agent." };
|
|
21
|
+
const trimmedHours = hoursText.trim();
|
|
22
|
+
if (!/^\d+$/.test(trimmedHours)) return { ok: false, message: `Enter a whole number of hours from ${MINIMUM_DELEGATION_HOURS} to ${MAXIMUM_DELEGATION_HOURS}.` };
|
|
23
|
+
const hours = Number(trimmedHours);
|
|
24
|
+
if (hours < MINIMUM_DELEGATION_HOURS || hours > MAXIMUM_DELEGATION_HOURS) {
|
|
25
|
+
return { ok: false, message: `A delegation lasts from ${MINIMUM_DELEGATION_HOURS} to ${MAXIMUM_DELEGATION_HOURS} hours.` };
|
|
26
|
+
}
|
|
27
|
+
return { ok: true, agentLabel, hours };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function delegationExpiryText(expiresAt: string, revokedAt: string | null, nowMilliseconds: number): string {
|
|
31
|
+
if (revokedAt) return "Revoked";
|
|
32
|
+
const remaining = Date.parse(expiresAt) - nowMilliseconds;
|
|
33
|
+
if (!Number.isFinite(remaining) || remaining <= 0) return "Expired";
|
|
34
|
+
const minutes = Math.ceil(remaining / 60_000);
|
|
35
|
+
if (minutes < 60) return `Expires in ${minutes} ${minutes === 1 ? "minute" : "minutes"}`;
|
|
36
|
+
const hours = Math.round(remaining / 3_600_000);
|
|
37
|
+
return `Expires in ${hours} ${hours === 1 ? "hour" : "hours"}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function isDelegationActive(record: CurationDelegationRecord, nowMilliseconds: number): boolean {
|
|
41
|
+
return !record.revokedAt && Date.parse(record.expiresAt) > nowMilliseconds;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function buildDelegatedCommandLine(input: { email: string; agentLabel: string; token: string; action?: "approve" | "reject" }): string {
|
|
45
|
+
const email = input.email.trim() || "<your-email>";
|
|
46
|
+
return `papyrus references ${input.action ?? "approve"} <reference-id> --on-behalf-of ${email} --agent ${input.agentLabel} --instruction "<the person's own words>" --delegation-token ${input.token}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function describeDelegationError(error: unknown): string {
|
|
50
|
+
const raw = error instanceof Error ? error.message : typeof error === "string" ? error : "";
|
|
51
|
+
const graphql = (error as { errors?: Array<{ message?: string }> } | null)?.errors?.[0]?.message ?? "";
|
|
52
|
+
const text = `${raw} ${graphql}`.toLowerCase();
|
|
53
|
+
if (text.includes("not authorized") || text.includes("unauthorized")) return "Your account is not allowed to manage delegations. Only editors and administrators can.";
|
|
54
|
+
if (text.includes("not found")) return "That delegation no longer exists.";
|
|
55
|
+
if (text.includes("only the person who granted")) return "Only the person who granted a delegation, or an administrator, can revoke it.";
|
|
56
|
+
if (text.includes("network") || text.includes("failed to fetch")) return "Could not reach the server. Check your connection and try again.";
|
|
57
|
+
const detail = (graphql || raw).trim();
|
|
58
|
+
return detail ? `The server refused the request: ${detail}` : "Something went wrong. Nothing was changed.";
|
|
59
|
+
}
|