@anthusai/papyrus 5.44.0 → 5.46.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.
Files changed (48) hide show
  1. package/amplify/data/resource.js +6 -0
  2. package/amplify/data/resource.ts +7 -0
  3. package/amplify/data/schema.js +6 -0
  4. package/amplify/data/schema.ts +7 -0
  5. package/amplify/functions/content-actions/handler.py +8 -0
  6. package/amplify/functions/model-attachment-upload/handler.ts +1 -0
  7. package/amplify/functions/monitoring-view/schema.js +8 -0
  8. package/amplify/functions/monitoring-view/schema.ts +8 -0
  9. package/bin/papyrus-app.mjs +12 -1
  10. package/components/monitoring-overview-view.tsx +52 -6
  11. package/components/monitoring-panel.tsx +61 -3
  12. package/components/news-desk-client-provider.tsx +16 -1
  13. package/components/news-desk-page.tsx +2 -1
  14. package/components/newsroom-access-gate.tsx +7 -5
  15. package/components/newsroom-client-shell.tsx +2 -1
  16. package/components/newsroom-ops-overview.tsx +63 -60
  17. package/components/newsroom-ops-shell.tsx +4 -2
  18. package/components/newsroom-organizer.css +49 -2
  19. package/components/newsroom-references-view.tsx +296 -23
  20. package/components/notification-cards.tsx +364 -0
  21. package/components/notification-center.tsx +319 -331
  22. package/components/reference-capture-client.ts +110 -0
  23. package/components/reference-capture-panel.tsx +180 -0
  24. package/components/reference-feed.tsx +502 -0
  25. package/components/reference-review-card.tsx +32 -80
  26. package/components/references-page-loading.tsx +61 -0
  27. package/components/references-prefetch.ts +52 -0
  28. package/components/review-rating-grid.tsx +177 -0
  29. package/components/topic-steering-workspace.tsx +43 -9
  30. package/components/use-reference-ratings.ts +132 -0
  31. package/lib/category-repository.ts +6 -1
  32. package/lib/content-actions-client.ts +18 -0
  33. package/lib/local-preview-feed-fixture.ts +63 -0
  34. package/lib/local-preview-synthetic-rejects.ts +41 -0
  35. package/lib/monitoring-client.ts +16 -2
  36. package/lib/monitoring-overview.ts +20 -1
  37. package/lib/monitoring-paging.ts +58 -0
  38. package/lib/newsroom-nav.ts +1 -1
  39. package/lib/newsroom-organizer-nav.ts +1 -1
  40. package/lib/notification-feed.ts +250 -0
  41. package/lib/reference-capture.ts +293 -0
  42. package/lib/reference-feed-cache.ts +170 -0
  43. package/lib/reference-feed-ranking.ts +235 -0
  44. package/lib/reference-preview.ts +112 -0
  45. package/lib/review-drafts.ts +8 -3
  46. package/lib/site-brand.ts +2 -0
  47. package/package.json +17 -1
  48. package/routes.manifest.json +1 -1
@@ -0,0 +1,63 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { normalizeReviewRow } from "./review-requests";
4
+ import type { CategorySteeringDashboard, ReferenceAttachmentRecord, ReferenceRecord, SemanticRelationRecord } from "./category-repository";
5
+
6
+ /**
7
+ * Local preview only (server side): when PAPYRUS_FEED_FIXTURE names a JSON file written by
8
+ * scripts/export-reference-feed-fixture.py, the demo newsroom shows those References, their previews and the Cyclotron's
9
+ * decisions instead of the invented sample. Without the variable, or without the file, nothing changes.
10
+ */
11
+ export type ReferenceFeedFixture = {
12
+ references: ReferenceRecord[];
13
+ referenceAttachments: ReferenceAttachmentRecord[];
14
+ semanticRelations: SemanticRelationRecord[];
15
+ };
16
+
17
+ export function loadReferenceFeedFixture(env: NodeJS.ProcessEnv = process.env): ReferenceFeedFixture | null {
18
+ const configured = env.PAPYRUS_FEED_FIXTURE?.trim();
19
+ if (!configured || env.NEXT_PUBLIC_PAPYRUS_LOCAL_PREVIEW !== "1") return null;
20
+ try {
21
+ const parsed = JSON.parse(fs.readFileSync(path.resolve(process.cwd(), configured), "utf8"));
22
+ if (parsed?.schema !== "papyrus-reference-feed-fixture/1" || !Array.isArray(parsed.references)) return null;
23
+ return {
24
+ references: parsed.references,
25
+ referenceAttachments: Array.isArray(parsed.referenceAttachments) ? parsed.referenceAttachments : [],
26
+ semanticRelations: Array.isArray(parsed.semanticRelations) ? parsed.semanticRelations : [],
27
+ };
28
+ } catch {
29
+ return null;
30
+ }
31
+ }
32
+
33
+ export type NotificationCenterFixture = NonNullable<CategorySteeringDashboard["notificationFixture"]>;
34
+
35
+ /**
36
+ * Local preview only: when PAPYRUS_NOTIFICATION_FIXTURE names a JSON file written by scripts/export-notification-center-fixture.py,
37
+ * the demo Notification Center shows those review requests and their drafts instead of the invented sample.
38
+ */
39
+ export function loadNotificationCenterFixture(env: NodeJS.ProcessEnv = process.env): NotificationCenterFixture | null {
40
+ const configured = env.PAPYRUS_NOTIFICATION_FIXTURE?.trim();
41
+ if (!configured || env.NEXT_PUBLIC_PAPYRUS_LOCAL_PREVIEW !== "1") return null;
42
+ try {
43
+ const parsed = JSON.parse(fs.readFileSync(path.resolve(process.cwd(), configured), "utf8"));
44
+ if (parsed?.schema !== "papyrus-notification-center-fixture/1" || !Array.isArray(parsed.requests)) return null;
45
+ return { requests: parsed.requests.map(normalizeReviewRow), drafts: Array.isArray(parsed.drafts) ? parsed.drafts : [] };
46
+ } catch {
47
+ return null;
48
+ }
49
+ }
50
+
51
+ export function withReferenceFeedFixture(dashboard: CategorySteeringDashboard): CategorySteeringDashboard {
52
+ const fixture = loadReferenceFeedFixture();
53
+ if (!fixture) return dashboard;
54
+ return {
55
+ ...dashboard,
56
+ notificationFixture: loadNotificationCenterFixture(),
57
+ references: fixture.references,
58
+ referenceAttachments: fixture.referenceAttachments,
59
+ semanticRelations: fixture.semanticRelations,
60
+ messages: [],
61
+ realDataPreview: true,
62
+ };
63
+ }
@@ -0,0 +1,41 @@
1
+ import type { RelevanceDecisionView } from "./relevance-decisions";
2
+
3
+ /** Strong Reject confidences, strongest first, for the preview's made-up predictions. */
4
+ const SYNTHETIC_REJECT_CONFIDENCE = [0.95, 0.91, 0.87, 0.83, 0.79, 0.75, 0.71, 0.67];
5
+
6
+ /**
7
+ * Local preview only. A young corpus can have no Reject predictions at all, so the Reject end of the feed cannot be reviewed on real
8
+ * data. `?feedSynthRejects=N` turns the N weakest Accept predictions of pending items into made-up Rejects, and the page says so
9
+ * in a banner. Never reachable outside local preview.
10
+ */
11
+ export function chooseSyntheticRejects(
12
+ decisions: ReadonlyMap<string, RelevanceDecisionView> | undefined,
13
+ pendingLineageIds: ReadonlySet<string>,
14
+ count: number,
15
+ ): string[] {
16
+ return [...(decisions ?? new Map()).entries()]
17
+ .filter(([lineageId, decision]) => pendingLineageIds.has(lineageId) && decision.label === (decision.positiveLabel ?? "include"))
18
+ .sort((left, right) => (left[1].confidence ?? 0) - (right[1].confidence ?? 0))
19
+ .slice(0, Math.max(0, Math.min(count, SYNTHETIC_REJECT_CONFIDENCE.length)))
20
+ .map(([lineageId]) => lineageId);
21
+ }
22
+
23
+ /** Applies the chosen ids, weakest first getting the lowest of the made-up confidences. The choice is made once, so rating a card never moves it. */
24
+ export function withSyntheticRejects(
25
+ decisions: ReadonlyMap<string, RelevanceDecisionView> | undefined,
26
+ chosen: readonly string[],
27
+ ): { decisions: Map<string, RelevanceDecisionView>; changed: number } {
28
+ const result = new Map(decisions ?? []);
29
+ let changed = 0;
30
+ chosen.forEach((lineageId, index) => {
31
+ const decision = result.get(lineageId);
32
+ if (!decision) return;
33
+ result.set(lineageId, {
34
+ ...decision,
35
+ label: decision.classes.find((label) => label !== (decision.positiveLabel ?? "include")) ?? "exclude",
36
+ confidence: SYNTHETIC_REJECT_CONFIDENCE[index],
37
+ });
38
+ changed += 1;
39
+ });
40
+ return { decisions: result, changed };
41
+ }
@@ -4,6 +4,8 @@ import { generateClient } from "aws-amplify/data";
4
4
  import { emailReferenceBatches } from "./monitoring-overview";
5
5
  import type {
6
6
  EmailDecisionResult,
7
+ MonitoringListName,
8
+ MonitoringPage,
7
9
  EmailReferenceImportItem,
8
10
  EmailReferenceImportOutcome,
9
11
  MonitoringEmailArticleResolution,
@@ -11,10 +13,10 @@ import type {
11
13
  MonitoringResult,
12
14
  } from "./monitoring-overview";
13
15
 
14
- type QueryField = "monitoringOverview" | "monitoringEmailSubmission" | "monitoringDecideEmailSubmission" | "monitoringResolveEmailArticleLink" | "intakeEmailSubmissionReferences" | "monitoringQueueEmailReference";
16
+ type QueryField = "monitoringOverview" | "monitoringPage" | "monitoringEmailSubmission" | "monitoringDecideEmailSubmission" | "monitoringResolveEmailArticleLink" | "intakeEmailSubmissionReferences" | "monitoringQueueEmailReference";
15
17
  type GraphQLResult = { data?: Partial<Record<QueryField, unknown>>; errors?: { message?: string; errorType?: string }[] };
16
18
 
17
- async function query<T>(field: QueryField, queryText: string, variables: Record<string, string | number>): Promise<T | { error: "forbidden" } | { state: "unavailable" }> {
19
+ async function query<T>(field: QueryField, queryText: string, variables: Record<string, string | number | null>): Promise<T | { error: "forbidden" } | { state: "unavailable" }> {
18
20
  const client = generateClient({ authMode: "userPool" });
19
21
  let result: GraphQLResult;
20
22
  try {
@@ -35,6 +37,18 @@ export async function loadMonitoringOverview(scope: "publication" | "all"): Prom
35
37
  `query MonitoringOverview($scope: String) { monitoringOverview(scope: $scope) }`, { scope });
36
38
  }
37
39
 
40
+ /**
41
+ * One more page of one list after `cursor` (null starts the list from its newest row). `heldOnly` lists only held
42
+ * email submissions, so a held message is findable however old it is.
43
+ */
44
+ export async function loadMonitoringPage(
45
+ list: MonitoringListName, cursor: string | null, scope: "publication" | "all", heldOnly = false,
46
+ ): Promise<MonitoringPage> {
47
+ return query<MonitoringPage>("monitoringPage",
48
+ `query MonitoringPage($list: String!, $cursor: String, $filter: String, $scope: String) { monitoringPage(list: $list, cursor: $cursor, filter: $filter, scope: $scope) }`,
49
+ { list, cursor, filter: heldOnly ? "held" : null, scope });
50
+ }
51
+
38
52
  /** Loads one receipt's safe plain-text rendering and its already extracted candidate links. */
39
53
  export async function loadMonitoringEmailSubmission(receiptId: string): Promise<MonitoringEmailDetails> {
40
54
  return query<MonitoringEmailDetails>("monitoringEmailSubmission",
@@ -38,6 +38,12 @@ export type MonitoringEmailSummary = {
38
38
  envelopeSender: string | null; headerFrom: string | null; warnings: string[];
39
39
  verdicts: Record<string, string>; linkCount: number; processedCount: number; unresolvedLinkCount: number;
40
40
  reviewRequired: boolean; errorCode: string | null; quarantine: EmailQuarantine | null;
41
+ /** Present when the same email was received more than once: one item, every copy listed. One decision covers all copies. */
42
+ group?: EmailCopyGroup;
43
+ };
44
+ export type EmailCopyGroup = {
45
+ fingerprint: string; count: number;
46
+ copies: { receiptId: string; observedAt: string | null; status: string; linkCount: number; processedCount: number; quarantine: EmailQuarantine | null }[];
41
47
  };
42
48
 
43
49
  /** Where a held email submission stands. `null` means it never needed review. */
@@ -46,7 +52,7 @@ export type EmailQuarantineState =
46
52
  | "expired" | "decision_failed" | "release_failed";
47
53
  export type EmailQuarantine = {
48
54
  state: EmailQuarantineState;
49
- decision: { action: "accept" | "reject"; by: string; at: string | null; reason: string } | null;
55
+ decision: { action: "accept" | "reject"; by: string; at: string | null; reason: string; viaGroup?: boolean } | null;
50
56
  failure: string | null;
51
57
  };
52
58
  export type EmailDecisionResult =
@@ -144,6 +150,17 @@ export type MonitoringFeed = {
144
150
  nextDueAt: string | null;
145
151
  };
146
152
 
153
+ /** The lists on the page that are paged. Page sizes are the server's; there is no total. */
154
+ export type MonitoringListName = "runs" | "routerRuns" | "items" | "emailSubmissions";
155
+ export type MonitoringCursors = Partial<Record<MonitoringListName, string | null>>;
156
+ export type MonitoringListRows = {
157
+ runs: MonitoringRun; routerRuns: MonitoringRouterRun; items: MonitoringItem; emailSubmissions: MonitoringEmailSummary;
158
+ };
159
+ /** One more page of one list. `cursor` is null when the list has ended. */
160
+ export type MonitoringPage<L extends MonitoringListName = MonitoringListName> =
161
+ | { list: L; rows: MonitoringListRows[L][]; cursor: string | null; hiddenNotRelevant?: number }
162
+ | { error: "forbidden" | "bad_request" } | { state: "unavailable" | "not_configured" };
163
+
147
164
  export type MonitoringOverview = {
148
165
  state: "ready" | "empty";
149
166
  asOf: string;
@@ -164,6 +181,8 @@ export type MonitoringOverview = {
164
181
  otherMonitors?: string[];
165
182
  emailSubmissions?: MonitoringEmailSummary[];
166
183
  hiddenNotRelevant: number;
184
+ /** Where each list continues; null or missing when it has no more rows. */
185
+ cursors?: MonitoringCursors;
167
186
  };
168
187
 
169
188
  export type MonitoringResult =
@@ -0,0 +1,58 @@
1
+ /** Merging loaded pages into the Monitoring lists. Kept free of other imports so the panel's tests can load it alone. */
2
+ import type {
3
+ MonitoringCursors,
4
+ MonitoringItem,
5
+ MonitoringListName,
6
+ MonitoringListRows,
7
+ MonitoringOverview,
8
+ } from "./monitoring-overview";
9
+
10
+ /** Rows the editor has loaded beyond the first page of each list, and where each list continues. */
11
+ export type MonitoringLoadedPages = Partial<{ [L in MonitoringListName]: { rows: MonitoringListRows[L][]; cursor: string | null } }>;
12
+
13
+ const ROW_KEYS: { [L in MonitoringListName]: (row: MonitoringListRows[L]) => string } = {
14
+ runs: (row) => `${row.runId}`,
15
+ routerRuns: (row) => `${row.runId}`,
16
+ items: (row) => `${row.monitorId ?? ""}|${row.sourceId}`,
17
+ emailSubmissions: (row) => row.receiptId,
18
+ };
19
+
20
+ /**
21
+ * The first page followed by loaded pages, with a row seen twice kept once. The refresh re-reads the first page, so
22
+ * a row that moved onto it from a later page must not be listed again; for items the highest revision wins.
23
+ */
24
+ export function mergeListPages<L extends MonitoringListName>(list: L, first: MonitoringListRows[L][], loaded: MonitoringListRows[L][]): MonitoringListRows[L][] {
25
+ const keyOf = ROW_KEYS[list] as (row: MonitoringListRows[L]) => string;
26
+ const seen = new Map<string, number>();
27
+ const merged: MonitoringListRows[L][] = [];
28
+ for (const row of [...first, ...loaded]) {
29
+ const key = keyOf(row);
30
+ const at = seen.get(key);
31
+ if (at === undefined) {
32
+ seen.set(key, merged.length);
33
+ merged.push(row);
34
+ } else if (list === "items" && (row as MonitoringItem).revision > (merged[at] as MonitoringItem).revision) {
35
+ merged[at] = row;
36
+ }
37
+ }
38
+ return merged;
39
+ }
40
+
41
+ /**
42
+ * What the page shows: the refreshed first page of every list plus whatever the editor loaded, with each list's
43
+ * cursor. With `held` the email list is replaced by the held-for-review list (its own pages).
44
+ */
45
+ export function withLoadedPages(overview: MonitoringOverview, loaded: MonitoringLoadedPages, held: MonitoringLoadedPages["emailSubmissions"] | null): MonitoringOverview {
46
+ const cursors: MonitoringCursors = {};
47
+ const lists: Partial<Record<MonitoringListName, unknown[]>> = {};
48
+ for (const list of ["runs", "routerRuns", "items", "emailSubmissions"] as const) {
49
+ const extra = loaded[list];
50
+ lists[list] = mergeListPages(list, (overview[list] ?? []) as never[], (extra?.rows ?? []) as never[]) as unknown[];
51
+ cursors[list] = extra ? extra.cursor : overview.cursors?.[list] ?? null;
52
+ }
53
+ if (held) {
54
+ lists.emailSubmissions = held.rows;
55
+ cursors.emailSubmissions = held.cursor;
56
+ }
57
+ return { ...overview, ...(lists as Pick<MonitoringOverview, MonitoringListName>), cursors };
58
+ }
@@ -21,7 +21,7 @@ export type NewsroomNavItem = {
21
21
  };
22
22
 
23
23
  export const NEWSROOM_OPS_NAV: NewsroomNavItem[] = [
24
- { id: "overview", label: "Overview", detail: "Newsroom home", href: newsroomHref() },
24
+ { id: "overview", label: "Needs me", detail: "Newsroom home", href: newsroomHref() },
25
25
  { id: "articles", label: getContentItemName().plural, detail: "Drafts & published", href: newsroomHref("articles") },
26
26
  { id: "assignments", label: "Assignments", detail: "Work queue", href: newsroomHref("assignments") },
27
27
  { id: "references", label: "References", detail: "Knowledge base", href: newsroomHref("references") },
@@ -8,7 +8,7 @@ export type OrganizerDestination = {
8
8
  };
9
9
  export const ORGANIZER_NAV_GROUPS: readonly { title: string; items: readonly OrganizerDestination[] }[] = [
10
10
  { title: "Editorial work", items: [
11
- { key: "overview", label: "Overview", description: "See notifications and work needing attention.", path: "/newsroom", primary: true },
11
+ { key: "overview", label: "Needs me", description: "See what needs you: drafts, references and reviews.", path: "/newsroom", primary: true },
12
12
  { key: "articles", label: getContentItemName().plural, description: `Follow ${contentItemPluralLowercase} across their stages and open one to write or revise it.`, path: `/newsroom/${contentItemBoardPathSegment()}`, alsoActiveOnPaths: ["/newsroom/articles"], primary: true },
13
13
  { key: "assignments", label: "Assignments", description: "Follow research and reporting work across its statuses and claim it.", path: "/newsroom/assignments" },
14
14
  { key: "drafts", label: "Drafts", description: "Read the story drafts that AI proposes, then accept or reject them.", path: "/newsroom/administration/proposals" },
@@ -0,0 +1,250 @@
1
+ /**
2
+ * The Notification Center as a feed of cards ("What needs me?"): which cards there are, how they are counted, filtered and
3
+ * worded. Pure functions, so the page and its tests share them. A review request that covers several AI drafts is not shown as a
4
+ * batch: each draft is its own card, with a small caption saying which run it came from. The request still closes itself when
5
+ * its last draft is decided (components/notification-center.tsx).
6
+ */
7
+ import type { ReferenceRecord } from "./category-repository";
8
+ import { referenceDisplayTitle, referenceLineageId } from "./newsroom-references";
9
+ import { normalizeReferenceCurationStatus } from "./reference-policy";
10
+ import type { ReferencePreview } from "./reference-preview";
11
+ import { draftDecisionOf, type DraftView } from "./review-drafts";
12
+ import { draftIdsOf, isDraftReview, proposalHref, sectionReviews, type DraftDecision, type ReviewRequest } from "./review-requests";
13
+ import { getNewsroomNavHref } from "./newsroom-nav";
14
+
15
+ export type NotificationFilter = "all" | "drafts" | "references" | "other";
16
+ export const NOTIFICATION_FILTERS: readonly NotificationFilter[] = ["all", "drafts", "references", "other"];
17
+ export const FILTER_LABELS: Record<NotificationFilter, string> = { all: "All", drafts: "Drafts", references: "References", other: "Other" };
18
+
19
+ export type DraftItem = {
20
+ kind: "draft";
21
+ /** `<request id>#<draft id>`. */
22
+ key: string;
23
+ request: ReviewRequest;
24
+ draftId: string;
25
+ /** Undefined while the Proposal is still loading. */
26
+ view: DraftView | undefined;
27
+ decision: DraftDecision | null;
28
+ };
29
+
30
+ /** A request that is decided as a whole: a hard gate, a soft review of something that is not a draft, or a flag. */
31
+ export type RequestItem = { kind: "request"; key: string; request: ReviewRequest };
32
+
33
+ export type NotificationItems = {
34
+ drafts: DraftItem[];
35
+ /** Hard gates first, then the other soft reviews, soonest deadline first. */
36
+ reviews: RequestItem[];
37
+ /** For your information: flags. */
38
+ flags: RequestItem[];
39
+ counts: NotificationCounts;
40
+ };
41
+
42
+ export type NotificationCounts = { drafts: number; references: number; other: number };
43
+
44
+ const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] as const;
45
+
46
+ /** "Oct 27", in UTC so the words do not change with the reader's time zone. */
47
+ export function shortDate(iso: string | null | undefined): string {
48
+ if (!iso) return "";
49
+ const date = new Date(iso);
50
+ if (Number.isNaN(date.getTime())) return "";
51
+ return `${MONTHS[date.getUTCMonth()]} ${date.getUTCDate()}`;
52
+ }
53
+
54
+ function sentenceCase(slug: string): string {
55
+ const spaced = slug.replace(/[-_.]+/g, " ").trim();
56
+ return spaced ? spaced[0].toUpperCase() + spaced.slice(1) : "";
57
+ }
58
+
59
+ /** Where a request came from, in words: the run its batch names ("Monitor stories run"), else the step that raised it. */
60
+ export function requestSource(request: Pick<ReviewRequest, "subject" | "raisedBy">): string {
61
+ if (request.subject.type === "batch") {
62
+ const named = request.subject.id.replace(/-\d{8}t\d{6}z.*$/i, "").replace(/-[0-9a-f]{6,}$/i, "");
63
+ if (/[a-z]{3,}/i.test(named)) return sentenceCase(named);
64
+ }
65
+ return sentenceCase(request.raisedBy) || "An automated step";
66
+ }
67
+
68
+ /** The small caption on a card that came out of a request: "from Monitor stories run · Oct 27". */
69
+ export function requestCaption(request: Pick<ReviewRequest, "subject" | "raisedBy" | "raisedAt">): string {
70
+ const when = shortDate(request.raisedAt);
71
+ return `from ${requestSource(request)}${when ? ` · ${when}` : ""}`;
72
+ }
73
+
74
+ const NO_FALLBACK = { by: "", at: "" } as const;
75
+
76
+ /**
77
+ * The cards for review requests. A draft review becomes one card per draft: undecided drafts of open requests, plus drafts decided in
78
+ * this visit (`stay`, keyed `<request id>#<draft id>`) so a card does not vanish under the editor's thumb. Drafts that are not Proposals
79
+ * on this site cannot be opened here; when none can, the request itself is one card that takes Accept or Reject.
80
+ */
81
+ export function buildNotificationItems(input: {
82
+ requests: ReviewRequest[];
83
+ drafts: Readonly<Record<string, DraftView | undefined>>;
84
+ missingDrafts: ReadonlySet<string>;
85
+ draftsState: "loading" | "ready" | "failed";
86
+ /** Draft keys and request ids decided in this visit. */
87
+ stay: ReadonlySet<string>;
88
+ referenceCount: number;
89
+ }): NotificationItems {
90
+ const sections = sectionReviews(input.requests);
91
+ const closedHere = input.requests.filter((request) => request.state !== "open" && input.stay.has(request.id));
92
+ const draftItems: DraftItem[] = [];
93
+ const requestLevel: ReviewRequest[] = [];
94
+ const draftRequests = [...sections.draftBatches, ...input.requests.filter((request) => request.state !== "open" && isDraftReview(request) && draftIdsOf(request).some((id) => input.stay.has(`${request.id}#${id}`)))];
95
+ for (const request of draftRequests) {
96
+ const available = draftIdsOf(request).filter((id) => !input.missingDrafts.has(id));
97
+ if (request.state === "open" && input.draftsState === "ready" && available.length === 0) {
98
+ requestLevel.push(request);
99
+ continue;
100
+ }
101
+ for (const draftId of available) {
102
+ const key = `${request.id}#${draftId}`;
103
+ const view = input.drafts[draftId];
104
+ const decision = draftDecisionOf(request.links.find((link) => link.id === draftId), view, NO_FALLBACK);
105
+ const waiting = request.state === "open" && !decision;
106
+ if (waiting || input.stay.has(key)) draftItems.push({ kind: "draft", key, request, draftId, view, decision });
107
+ }
108
+ }
109
+ const asItem = (request: ReviewRequest): RequestItem => ({ kind: "request", key: request.id, request });
110
+ const standing = (request: ReviewRequest) => request.state === "open" || input.stay.has(request.id);
111
+ const reviews = [
112
+ ...sections.hardGates,
113
+ ...sections.softReviews,
114
+ ...requestLevel,
115
+ ...closedHere.filter((request) => request.mode !== "flag" && !isDraftReview(request)),
116
+ ].filter(standing).map(asItem);
117
+ const flags = [...sections.flags, ...closedHere.filter((request) => request.mode === "flag")].filter(standing).map(asItem);
118
+ return {
119
+ drafts: draftItems,
120
+ reviews: [...new Map(reviews.map((item) => [item.key, item])).values()],
121
+ flags: [...new Map(flags.map((item) => [item.key, item])).values()],
122
+ counts: {
123
+ drafts: draftItems.filter((item) => !item.decision).length,
124
+ references: input.referenceCount,
125
+ other: [...reviews, ...flags].filter((item) => item.request.state === "open").length,
126
+ },
127
+ };
128
+ }
129
+
130
+ function plural(count: number, one: string, many = `${one}s`): string {
131
+ return `${count} ${count === 1 ? one : many}`;
132
+ }
133
+
134
+ /** "3 drafts and 12 references waiting for you"; "All caught up" when nothing waits. */
135
+ export function waitingText(counts: NotificationCounts): string {
136
+ const parts = [
137
+ counts.drafts ? plural(counts.drafts, "draft") : null,
138
+ counts.references ? plural(counts.references, "reference") : null,
139
+ counts.other ? plural(counts.other, "other item") : null,
140
+ ].filter((part): part is string => part !== null);
141
+ if (!parts.length) return "All caught up";
142
+ const list = parts.length === 1 ? parts[0] : `${parts.slice(0, -1).join(", ")} and ${parts[parts.length - 1]}`;
143
+ return `${list} waiting for you`;
144
+ }
145
+
146
+ export function filterCount(filter: NotificationFilter, counts: NotificationCounts): number {
147
+ return filter === "all" ? counts.drafts + counts.references + counts.other : counts[filter];
148
+ }
149
+
150
+ /** Which sections a filter shows. */
151
+ export function showsSection(filter: NotificationFilter, section: "drafts" | "references" | "other"): boolean {
152
+ return filter === "all" || filter === section;
153
+ }
154
+
155
+ // --- sources, pictures ----------------------------------------------------------------------------------------
156
+
157
+ function normalizedAddress(address: string): string {
158
+ try {
159
+ const url = new URL(address);
160
+ return `${url.hostname.replace(/^www\./i, "").toLowerCase()}${url.pathname.replace(/\/+$/, "")}${url.search}`;
161
+ } catch {
162
+ return address.trim().toLowerCase();
163
+ }
164
+ }
165
+
166
+ /** The previews of References by the address they were saved from, so a draft's cited source finds its picture. */
167
+ export function previewsBySourceAddress(references: ReferenceRecord[], previews: ReadonlyMap<string, ReferencePreview>): Map<string, ReferencePreview> {
168
+ const found = new Map<string, ReferencePreview>();
169
+ for (const reference of references) {
170
+ const preview = previews.get(referenceLineageId(reference));
171
+ if (preview && reference.sourceUri) found.set(normalizedAddress(reference.sourceUri), preview);
172
+ }
173
+ return found;
174
+ }
175
+
176
+ /** The first cited source that has a picture, with the site name its preview gives. */
177
+ export function draftPicture(view: DraftView | undefined, bySource: ReadonlyMap<string, ReferencePreview>): { imageUrl: string | null; siteName: string | null; host: string | null } {
178
+ const sources = view?.sources ?? [];
179
+ const host = sources.find((source) => source.url)?.url ? sourceHost(sources.find((source) => source.url)!.url!) : null;
180
+ for (const source of sources) {
181
+ const preview = source.url ? bySource.get(normalizedAddress(source.url)) : undefined;
182
+ if (preview?.imageUrl) return { imageUrl: preview.imageUrl, siteName: preview.siteName, host: source.url ? sourceHost(source.url) : host };
183
+ }
184
+ return { imageUrl: null, siteName: null, host };
185
+ }
186
+
187
+ export function sourceHost(address: string): string | null {
188
+ try {
189
+ return new URL(address).hostname.replace(/^www\./i, "") || null;
190
+ } catch {
191
+ return null;
192
+ }
193
+ }
194
+
195
+ // --- what you decided ------------------------------------------------------------------------------------------
196
+
197
+ export type RecentDecision = {
198
+ key: string;
199
+ kind: "draft" | "reference" | "request";
200
+ word: "Accepted" | "Rejected" | "Acknowledged" | "Answered";
201
+ choice: "accept" | "reject" | "acknowledge" | "answer";
202
+ title: string;
203
+ at: string;
204
+ href: string | null;
205
+ /** The editor's why, when there is one. */
206
+ note: string | null;
207
+ };
208
+
209
+ function requestDecision(request: ReviewRequest): Pick<RecentDecision, "word" | "choice"> | null {
210
+ const answer = request.answer;
211
+ if (request.state !== "answered" || !answer) return null;
212
+ if (answer.decision === "acknowledge" || (answer.decision === "approve" && request.mode === "flag")) return { word: "Acknowledged", choice: "acknowledge" };
213
+ if (answer.decision === "approve") return { word: "Accepted", choice: "accept" };
214
+ if (answer.decision === "reject") return { word: "Rejected", choice: "reject" };
215
+ return { word: "Answered", choice: "answer" };
216
+ }
217
+
218
+ /** What the editor decided most recently, newest first: drafts, other requests and References. "Last few", so a small default. */
219
+ export function recentDecisions(requests: ReviewRequest[], references: ReferenceRecord[], demo = false, limit = 5): RecentDecision[] {
220
+ const found = new Map<string, RecentDecision>();
221
+ for (const request of requests) {
222
+ for (const link of request.links) {
223
+ if (!link.decision || !link.id) continue;
224
+ found.set(`${request.id}#${link.id}`, {
225
+ key: `${request.id}#${link.id}`, kind: "draft", word: link.decision.choice === "accept" ? "Accepted" : "Rejected", choice: link.decision.choice,
226
+ title: link.decision.title ?? link.label ?? link.id, at: link.decision.at, href: proposalHref(link.id, demo), note: link.decision.why ?? null,
227
+ });
228
+ }
229
+ for (const entry of request.answer?.drafts ?? []) {
230
+ found.set(`${request.id}#${entry.id}`, {
231
+ key: `${request.id}#${entry.id}`, kind: "draft", word: entry.choice === "accept" ? "Accepted" : "Rejected", choice: entry.choice,
232
+ title: entry.title ?? entry.id, at: entry.at, href: proposalHref(entry.id, demo), note: entry.why ?? null,
233
+ });
234
+ }
235
+ const whole = requestDecision(request);
236
+ if (whole && !request.answer?.drafts?.length) {
237
+ found.set(request.id, { key: request.id, kind: "request", ...whole, title: request.reason, at: request.answer!.at, href: null, note: request.answer!.feedback });
238
+ }
239
+ }
240
+ for (const reference of references) {
241
+ const status = normalizeReferenceCurationStatus(reference.curationStatus);
242
+ if ((status !== "accepted" && status !== "rejected") || !reference.curationStatusUpdatedAt) continue;
243
+ const lineageId = referenceLineageId(reference);
244
+ found.set(`reference#${lineageId}`, {
245
+ key: `reference#${lineageId}`, kind: "reference", word: status === "accepted" ? "Accepted" : "Rejected", choice: status === "accepted" ? "accept" : "reject",
246
+ title: referenceDisplayTitle(reference), at: reference.curationStatusUpdatedAt, href: getNewsroomNavHref(`/newsroom/references/${encodeURIComponent(lineageId)}`, demo), note: null,
247
+ });
248
+ }
249
+ return [...found.values()].filter((entry) => entry.at).sort((a, b) => b.at.localeCompare(a.at)).slice(0, limit);
250
+ }