@anthusai/papyrus 5.50.0 → 5.52.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.
@@ -103,7 +103,8 @@ import { NewsroomCurationBootstrap } from "./newsroom-curation-bootstrap";
103
103
  import { NewsroomReferencesView } from "./newsroom-references-view";
104
104
  import { NewsroomAssignmentsView } from "./newsroom-assignments-view";
105
105
  import { NewsroomTopicsView } from "./newsroom-topics-view";
106
- import { NewsroomOpsSearchButton, NewsroomOpsSectionIntro, NewsroomOpsShell, NewsroomOpsStatusBanner, type NewsroomNavCount } from "./newsroom-ops-shell";
106
+ import { MarkdownContext } from "./markdown-context";
107
+ import { NewsroomOpsSectionIntro, NewsroomOpsShell, NewsroomOpsStatusBanner, type NewsroomNavCount } from "./newsroom-ops-shell";
107
108
  import { Button, buttonVariants } from "./ui/button";
108
109
  import { Badge } from "./ui/badge";
109
110
  import { Tabs, TabsContent, TabsList, TabsTrigger } from "./ui/tabs";
@@ -3060,14 +3061,8 @@ function NewsDeskDashboard({
3060
3061
  {dashboard.isDemo ? (
3061
3062
  dashboard.realDataPreview ? null : <Badge variant="outline">Demo</Badge>
3062
3063
  ) : (
3063
- <Link className={cn(buttonVariants({ variant: "ghost", size: "sm" }))} href="/settings">Settings</Link>
3064
+ <Link className={cn(buttonVariants({ variant: "ghost", size: "sm" }), "organizer-header-settings")} href="/settings">Settings</Link>
3064
3065
  )}
3065
- {canEdit && editorShellReady && !dashboard.isDemo ? (
3066
- <NewsroomOpsSearchButton
3067
- disabled={false}
3068
- onPress={activeTab === "search" ? focusNewsroomSearchForm : topBarSearchControl.open}
3069
- />
3070
- ) : null}
3071
3066
  <NewsroomConsoleProgressToggle />
3072
3067
  </>
3073
3068
  )}
@@ -9861,415 +9856,6 @@ function SearchDeskView({
9861
9856
  );
9862
9857
  }
9863
9858
 
9864
- function MarkdownContext({ className, text }: { className?: string; text: string }) {
9865
- const blocks = useMemo(() => parseMarkdownBlocks(normalizeMarkdownForRendering(text)), [text]);
9866
- return (
9867
- <div className={className ? `news-desk-markdown-context ${className}` : "news-desk-markdown-context"}>
9868
- {blocks.map((block, index) => renderMarkdownBlock(block, index))}
9869
- </div>
9870
- );
9871
- }
9872
-
9873
- type MarkdownBlock =
9874
- | { type: "code"; text: string }
9875
- | { type: "heading"; depth: number; text: string }
9876
- | { type: "list"; items: string[]; ordered: boolean }
9877
- | { type: "table"; headers: string[]; rows: string[][] }
9878
- | { type: "paragraph"; text: string };
9879
-
9880
- function normalizeMarkdownForRendering(text: string): string {
9881
- const trimmed = text.trim();
9882
- const fencedMarkdown = /^```(?:markdown|md|mdx)?\s*\n([\s\S]*?)\n```$/i.exec(trimmed);
9883
- const unwrapped = fencedMarkdown ? (fencedMarkdown[1] ?? "") : text;
9884
- return normalizeMultilineMarkdownLinks(unwrapped);
9885
- }
9886
-
9887
- function parseMarkdownBlocks(text: string): MarkdownBlock[] {
9888
- const blocks: MarkdownBlock[] = [];
9889
- const lines = text.split(/\r?\n/);
9890
- let paragraph: string[] = [];
9891
- let list: string[] = [];
9892
- let listOrdered = false;
9893
- let code: string[] = [];
9894
- let inCode = false;
9895
- const flushParagraph = () => {
9896
- if (paragraph.length) {
9897
- blocks.push({ type: "paragraph", text: paragraph.join(" ").trim() });
9898
- paragraph = [];
9899
- }
9900
- };
9901
- const flushList = () => {
9902
- if (list.length) {
9903
- blocks.push({ type: "list", items: list, ordered: listOrdered });
9904
- list = [];
9905
- listOrdered = false;
9906
- }
9907
- };
9908
- for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) {
9909
- const line = lines[lineIndex];
9910
- if (line.trim().startsWith("```")) {
9911
- if (inCode) {
9912
- blocks.push({ type: "code", text: code.join("\n") });
9913
- code = [];
9914
- inCode = false;
9915
- } else {
9916
- flushParagraph();
9917
- flushList();
9918
- inCode = true;
9919
- }
9920
- continue;
9921
- }
9922
- if (inCode) {
9923
- code.push(line);
9924
- continue;
9925
- }
9926
- const trimmed = line.trim();
9927
- if (!trimmed) {
9928
- flushParagraph();
9929
- flushList();
9930
- continue;
9931
- }
9932
- if (
9933
- lineIndex + 1 < lines.length
9934
- && looksLikeMarkdownTableHeaderLine(line)
9935
- && isMarkdownTableDividerLine(lines[lineIndex + 1] ?? "")
9936
- ) {
9937
- flushParagraph();
9938
- flushList();
9939
- const headerCells = splitMarkdownTableCells(line);
9940
- const dividerCells = splitMarkdownTableCells(lines[lineIndex + 1] ?? "");
9941
- const columnCount = Math.max(headerCells.length, dividerCells.length, 1);
9942
- const headers = normalizeMarkdownTableCells(headerCells, columnCount);
9943
- const rows: string[][] = [];
9944
- lineIndex += 2;
9945
- while (lineIndex < lines.length) {
9946
- const rowLine = lines[lineIndex] ?? "";
9947
- if (!looksLikeMarkdownTableRowLine(rowLine)) {
9948
- lineIndex -= 1;
9949
- break;
9950
- }
9951
- rows.push(normalizeMarkdownTableCells(splitMarkdownTableCells(rowLine), columnCount));
9952
- lineIndex += 1;
9953
- }
9954
- if (lineIndex >= lines.length) lineIndex -= 1;
9955
- blocks.push({ type: "table", headers, rows });
9956
- continue;
9957
- }
9958
- const heading = /^(#{1,4})\s+(.+)$/.exec(trimmed);
9959
- if (heading) {
9960
- flushParagraph();
9961
- flushList();
9962
- blocks.push({ type: "heading", depth: heading[1].length, text: heading[2].trim() });
9963
- continue;
9964
- }
9965
- const bullet = /^(?:([-*+])\s+|(\d+)[.)]\s+)(.+)$/.exec(trimmed);
9966
- if (bullet) {
9967
- flushParagraph();
9968
- const ordered = Boolean(bullet[2]);
9969
- if (list.length && listOrdered !== ordered) flushList();
9970
- listOrdered = ordered;
9971
- list.push((bullet[3] ?? "").trim());
9972
- continue;
9973
- }
9974
- flushList();
9975
- paragraph.push(trimmed);
9976
- }
9977
- flushParagraph();
9978
- flushList();
9979
- if (code.length) blocks.push({ type: "code", text: code.join("\n") });
9980
- return blocks;
9981
- }
9982
-
9983
- function renderMarkdownBlock(block: MarkdownBlock, index: number): ReactNode {
9984
- const key = `${block.type}-${index}`;
9985
- if (block.type === "code") return <pre className="news-desk-analysis-command" key={key}>{block.text}</pre>;
9986
- if (block.type === "list") {
9987
- const ListTag = block.ordered ? "ol" : "ul";
9988
- return (
9989
- <ListTag key={key}>
9990
- {block.items.map((item, itemIndex) => <li key={`${key}-${itemIndex}`}>{renderInlineMarkdown(item)}</li>)}
9991
- </ListTag>
9992
- );
9993
- }
9994
- if (block.type === "heading") {
9995
- if (block.depth <= 1) return <h3 key={key}>{block.text}</h3>;
9996
- if (block.depth === 2) return <h4 key={key}>{block.text}</h4>;
9997
- return <h5 key={key}>{block.text}</h5>;
9998
- }
9999
- if (block.type === "table") {
10000
- return (
10001
- <table key={key}>
10002
- <thead>
10003
- <tr>
10004
- {block.headers.map((header, headerIndex) => (
10005
- <th key={`${key}-head-${headerIndex}`} scope="col">{renderInlineMarkdown(header)}</th>
10006
- ))}
10007
- </tr>
10008
- </thead>
10009
- {block.rows.length ? (
10010
- <tbody>
10011
- {block.rows.map((row, rowIndex) => (
10012
- <tr key={`${key}-row-${rowIndex}`}>
10013
- {row.map((cell, cellIndex) => (
10014
- <td key={`${key}-row-${rowIndex}-cell-${cellIndex}`}>{renderInlineMarkdown(cell)}</td>
10015
- ))}
10016
- </tr>
10017
- ))}
10018
- </tbody>
10019
- ) : null}
10020
- </table>
10021
- );
10022
- }
10023
- return <p key={key}>{renderInlineMarkdown(block.text)}</p>;
10024
- }
10025
-
10026
- function renderInlineMarkdown(text: string): ReactNode[] {
10027
- const nodes: ReactNode[] = [];
10028
- let index = 0;
10029
- let plain = "";
10030
- const flushPlain = () => {
10031
- if (!plain) return;
10032
- nodes.push(plain);
10033
- plain = "";
10034
- };
10035
-
10036
- while (index < text.length) {
10037
- const linkLike = parseMarkdownLinkLikeAt(text, index);
10038
- if (linkLike) {
10039
- flushPlain();
10040
- if (!linkLike.image) {
10041
- const href = normalizeInlineMarkdownHref(linkLike.hrefRaw);
10042
- const label = collapseMarkdownLinkLabel(linkLike.label);
10043
- if (href) {
10044
- nodes.push(
10045
- <a href={href} key={`${index}-link`} rel="noopener noreferrer" target="_blank">
10046
- {renderInlineMarkdown(label)}
10047
- </a>,
10048
- );
10049
- } else {
10050
- nodes.push(linkLike.raw);
10051
- }
10052
- }
10053
- index += linkLike.length;
10054
- continue;
10055
- }
10056
-
10057
- if (text.startsWith("**", index) || text.startsWith("__", index)) {
10058
- const marker = text.slice(index, index + 2);
10059
- const end = text.indexOf(marker, index + 2);
10060
- if (end > index + 2) {
10061
- flushPlain();
10062
- nodes.push(<strong key={`${index}-strong`}>{renderInlineMarkdown(text.slice(index + 2, end))}</strong>);
10063
- index = end + 2;
10064
- continue;
10065
- }
10066
- }
10067
-
10068
- if (text.startsWith("~~", index)) {
10069
- const end = text.indexOf("~~", index + 2);
10070
- if (end > index + 2) {
10071
- flushPlain();
10072
- nodes.push(<del key={`${index}-strike`}>{renderInlineMarkdown(text.slice(index + 2, end))}</del>);
10073
- index = end + 2;
10074
- continue;
10075
- }
10076
- }
10077
-
10078
- if (text[index] === "*" || text[index] === "_") {
10079
- const marker = text[index];
10080
- const end = text.indexOf(marker, index + 1);
10081
- if (end > index + 1) {
10082
- flushPlain();
10083
- nodes.push(<em key={`${index}-em`}>{renderInlineMarkdown(text.slice(index + 1, end))}</em>);
10084
- index = end + 1;
10085
- continue;
10086
- }
10087
- }
10088
-
10089
- if (text[index] === "`") {
10090
- const end = text.indexOf("`", index + 1);
10091
- if (end > index + 1) {
10092
- flushPlain();
10093
- nodes.push(<code key={`${index}-code`}>{text.slice(index + 1, end)}</code>);
10094
- index = end + 1;
10095
- continue;
10096
- }
10097
- }
10098
-
10099
- const autolinkMatch = /^(<https?:\/\/[^>\s]+>|https?:\/\/[^\s<]+)/.exec(text.slice(index));
10100
- if (autolinkMatch) {
10101
- flushPlain();
10102
- const token = autolinkMatch[1];
10103
- const href = normalizeInlineMarkdownHref(token);
10104
- if (href) {
10105
- nodes.push(
10106
- <a href={href} key={`${index}-autolink`} rel="noopener noreferrer" target="_blank">
10107
- {href}
10108
- </a>,
10109
- );
10110
- } else {
10111
- nodes.push(token);
10112
- }
10113
- index += token.length;
10114
- continue;
10115
- }
10116
-
10117
- plain += text[index];
10118
- index += 1;
10119
- }
10120
-
10121
- flushPlain();
10122
- return nodes;
10123
- }
10124
-
10125
- function normalizeInlineMarkdownHref(rawHref: string): string | null {
10126
- const href = extractMarkdownHrefDestination(rawHref);
10127
- if (!href) return null;
10128
- if (/^(https?:\/\/|mailto:)/i.test(href)) return href;
10129
- return null;
10130
- }
10131
-
10132
- function normalizeMultilineMarkdownLinks(text: string): string {
10133
- let normalized = "";
10134
- let index = 0;
10135
- while (index < text.length) {
10136
- const linkLike = parseMarkdownLinkLikeAt(text, index);
10137
- if (!linkLike || linkLike.image || !linkLike.raw.includes("\n")) {
10138
- normalized += text[index];
10139
- index += 1;
10140
- continue;
10141
- }
10142
- const href = extractMarkdownHrefDestination(linkLike.hrefRaw);
10143
- const label = collapseMarkdownLinkLabel(linkLike.label);
10144
- if (!href || !label) {
10145
- normalized += linkLike.raw;
10146
- index += linkLike.length;
10147
- continue;
10148
- }
10149
- normalized += `[${label}](${href})`;
10150
- index += linkLike.length;
10151
- }
10152
- return normalized;
10153
- }
10154
-
10155
- function collapseMarkdownLinkLabel(label: string): string {
10156
- const withoutImages = label.replace(/!\[[^\]]*\]\((?:[^)(]|\([^)(]*\))*\)/g, " ");
10157
- return withoutImages
10158
- .replace(/\r?\n+/g, " ")
10159
- .replace(/(^|\s)#{1,6}\s+/g, "$1")
10160
- .replace(/\s+/g, " ")
10161
- .trim();
10162
- }
10163
-
10164
- function extractMarkdownHrefDestination(rawHref: string): string {
10165
- const trimmed = rawHref.trim();
10166
- if (!trimmed) return "";
10167
- if (trimmed.startsWith("<")) {
10168
- const closing = trimmed.indexOf(">");
10169
- if (closing > 1) return trimmed.slice(1, closing).trim();
10170
- }
10171
- const match = /^(\S+)/.exec(trimmed);
10172
- return match?.[1]?.trim() ?? "";
10173
- }
10174
-
10175
- function parseMarkdownLinkLikeAt(
10176
- source: string,
10177
- startIndex: number,
10178
- ): { hrefRaw: string; image: boolean; label: string; length: number; raw: string } | null {
10179
- const image = source.startsWith("![", startIndex);
10180
- const link = source[startIndex] === "[";
10181
- if (!image && !link) return null;
10182
- const labelStart = startIndex + (image ? 2 : 1);
10183
- const labelEnd = findMatchingBracketIndex(source, labelStart, "[", "]");
10184
- if (labelEnd < 0) return null;
10185
- let next = labelEnd + 1;
10186
- while (next < source.length && /\s/.test(source[next])) next += 1;
10187
- if (source[next] !== "(") return null;
10188
- const hrefStart = next + 1;
10189
- const hrefEnd = findMatchingBracketIndex(source, hrefStart, "(", ")");
10190
- if (hrefEnd < 0) return null;
10191
- const raw = source.slice(startIndex, hrefEnd + 1);
10192
- return {
10193
- image,
10194
- label: source.slice(labelStart, labelEnd),
10195
- hrefRaw: source.slice(hrefStart, hrefEnd),
10196
- length: raw.length,
10197
- raw,
10198
- };
10199
- }
10200
-
10201
- function findMatchingBracketIndex(source: string, contentStart: number, open: string, close: string): number {
10202
- let depth = 1;
10203
- for (let index = contentStart; index < source.length; index += 1) {
10204
- const ch = source[index];
10205
- if (ch === "\\") {
10206
- index += 1;
10207
- continue;
10208
- }
10209
- if (ch === open) {
10210
- depth += 1;
10211
- continue;
10212
- }
10213
- if (ch !== close) continue;
10214
- depth -= 1;
10215
- if (depth === 0) return index;
10216
- }
10217
- return -1;
10218
- }
10219
-
10220
- function looksLikeMarkdownTableHeaderLine(line: string): boolean {
10221
- return /\|/.test(line.trim());
10222
- }
10223
-
10224
- function looksLikeMarkdownTableRowLine(line: string): boolean {
10225
- const trimmed = line.trim();
10226
- if (!trimmed) return false;
10227
- if (!/\|/.test(trimmed)) return false;
10228
- if (isMarkdownTableDividerLine(trimmed)) return false;
10229
- return true;
10230
- }
10231
-
10232
- function isMarkdownTableDividerLine(line: string): boolean {
10233
- const cells = splitMarkdownTableCells(line);
10234
- if (!cells.length) return false;
10235
- return cells.every((cell) => /^:?-{3,}:?$/.test(cell.trim()));
10236
- }
10237
-
10238
- function splitMarkdownTableCells(line: string): string[] {
10239
- const trimmed = line.trim().replace(/^\|/, "").replace(/\|$/, "");
10240
- if (!trimmed) return [];
10241
- const cells: string[] = [];
10242
- let current = "";
10243
- let escaping = false;
10244
- for (let index = 0; index < trimmed.length; index += 1) {
10245
- const ch = trimmed[index];
10246
- if (escaping) {
10247
- current += ch;
10248
- escaping = false;
10249
- continue;
10250
- }
10251
- if (ch === "\\") {
10252
- escaping = true;
10253
- current += ch;
10254
- continue;
10255
- }
10256
- if (ch === "|") {
10257
- cells.push(current.trim());
10258
- current = "";
10259
- continue;
10260
- }
10261
- current += ch;
10262
- }
10263
- cells.push(current.trim());
10264
- return cells;
10265
- }
10266
-
10267
- function normalizeMarkdownTableCells(cells: string[], columnCount: number): string[] {
10268
- const normalized = cells.slice(0, columnCount);
10269
- while (normalized.length < columnCount) normalized.push("");
10270
- return normalized;
10271
- }
10272
-
10273
9859
  function PayloadDetailBlock({
10274
9860
  fallback,
10275
9861
  hideLabel = false,
@@ -14571,17 +14157,6 @@ function buildNewsroomSearchHref(request: NewsroomSearchRequest, demo?: boolean)
14571
14157
  return toPublicNewsroomPath(queryString ? `/newsroom/search?${queryString}` : "/newsroom/search");
14572
14158
  }
14573
14159
 
14574
- function focusNewsroomSearchForm() {
14575
- if (typeof window === "undefined") return;
14576
- const input = document.getElementById("newsroom-search-query");
14577
- if (input instanceof HTMLElement) {
14578
- input.focus();
14579
- input.scrollIntoView({ behavior: "smooth", block: "center" });
14580
- return;
14581
- }
14582
- window.dispatchEvent(new CustomEvent("papyrus:newsroom-search-focus"));
14583
- }
14584
-
14585
14160
  function resolveCurrentRouteAnchor(activeTab: NewsDeskTab): KnowledgeQueryAnchor | null {
14586
14161
  if (typeof window === "undefined") return null;
14587
14162
  const url = new URL(window.location.href);
@@ -35,12 +35,14 @@ export type CorrectReferenceCaptureInput = {
35
35
  referenceId: string;
36
36
  /** The full corrected or pasted text. */
37
37
  text?: string;
38
+ /** The id of a PDF the editor uploaded to this Reference (a ModelAttachment); the server reads its text. Sent instead of `text`. */
39
+ pdfAttachmentId?: string;
38
40
  grobid?: Record<string, unknown>;
39
41
  /** Required with text; alone it is just a label on the capture. */
40
42
  assessment?: CaptureAssessment;
41
43
  note?: string;
42
44
  };
43
- export type CorrectReferenceCaptureResult = { ok: true; referenceId: string; status: string; edits: unknown[]; label: unknown; summaryStale: boolean };
45
+ export type CorrectReferenceCaptureResult = { ok: true; referenceId: string; status: string; edits: Array<{ sortKey?: string; via?: string }>; label: unknown; summaryStale: boolean };
44
46
 
45
47
  export class ContentActionFailure extends Error {
46
48
  readonly errors: ContentActionError[];
@@ -167,3 +167,21 @@ export function parseAssignmentIdFromNewsroomPathname(pathname: string | null |
167
167
  return segment;
168
168
  }
169
169
  }
170
+
171
+ /** The Notification Center's deep link to one item: `/newsroom/needs-me/<id>` (public `/needs-me/<id>` on a root-path newsroom). */
172
+ export function buildNeedsMeItemPath(itemId: string): string {
173
+ return toPublicNewsroomPath(`/newsroom/needs-me/${encodeURIComponent(itemId)}`);
174
+ }
175
+
176
+ /** Item id from `/newsroom/needs-me/<id>`, else null. */
177
+ export function parseNeedsMeItemIdFromNewsroomPathname(pathname: string | null | undefined): string | null {
178
+ const internalPathname = pathname ? browserPathToInternalNewsroomPath(pathname) : null;
179
+ if (!internalPathname?.startsWith("/newsroom/needs-me/")) return null;
180
+ const segment = internalPathname.slice("/newsroom/needs-me/".length).split("/")[0]?.trim() ?? "";
181
+ if (!segment) return null;
182
+ try {
183
+ return decodeURIComponent(segment);
184
+ } catch {
185
+ return segment;
186
+ }
187
+ }
@@ -96,7 +96,31 @@ export function formatReferenceListDate(reference: ReferenceRecord): string {
96
96
  }
97
97
 
98
98
  export function referenceDisplayTitle(reference: ReferenceRecord): string {
99
- return reference.title?.trim() || reference.externalItemId;
99
+ const title = reference.title?.trim();
100
+ if (!title) return reference.externalItemId;
101
+ let host: string | null = null;
102
+ try { host = reference.sourceUri ? new URL(reference.sourceUri).hostname : null; } catch { host = null; }
103
+ return withoutSiteName(title, host);
104
+ }
105
+
106
+ const TITLE_SEPARATORS = [" | ", " – ", " — ", " - "] as const;
107
+
108
+ /**
109
+ * A page title without the site's name tacked on: "Auditing employment algorithms | Brookings" reads "Auditing employment
110
+ * algorithms". Only a last segment that names the site (its host's name, such as "brookings" in brookings.edu) is dropped,
111
+ * and with a pipe, which sites use for furniture, the section names before it go too ("… | Poverty and Development | Al Jazeera").
112
+ */
113
+ export function withoutSiteName(title: string, host: string | null): string {
114
+ const label = (host ?? "").toLowerCase().replace(/^www\./, "").split(".").slice(0, -1).filter((part) => part.length >= 4).at(-1);
115
+ if (!label) return title;
116
+ const plain = (text: string) => text.toLowerCase().replace(/[^a-z0-9]/g, "");
117
+ // The separator whose last segment is shortest wins, so "… groups – report - Amnesty International" keeps "– report".
118
+ const match = TITLE_SEPARATORS.map((separator) => ({ separator, parts: title.split(separator) }))
119
+ .filter(({ parts }) => parts.length > 1 && plain(parts.at(-1)!).includes(label))
120
+ .sort((left, right) => left.parts.at(-1)!.length - right.parts.at(-1)!.length)[0];
121
+ if (!match) return title;
122
+ const kept = match.separator === " | " ? match.parts[0] : match.parts.slice(0, -1).join(match.separator);
123
+ return kept.trim() || title;
100
124
  }
101
125
 
102
126
  export function referenceStatusLabel(status: string): string {
@@ -23,7 +23,7 @@ export type CurationCard = {
23
23
  editedSignals: Record<string, unknown> | null;
24
24
  summary: { status?: string; text?: string; stale?: boolean; reason?: string } | null;
25
25
  grobid: { status?: string; reason?: string; title?: string; authorCount?: number; hasAbstract?: boolean; sectionCount?: number; referenceCount?: number; fieldsSortKey?: string } | null;
26
- current: { textSortKey?: string; grobidSortKey?: string } | null;
26
+ current: { textSortKey?: string; grobidSortKey?: string; textVia?: string } | null;
27
27
  labels: Array<{ kind?: string; assessment?: string; labeledAt?: string }>;
28
28
  };
29
29
 
@@ -196,8 +196,9 @@ export function captureStatus(attachments: readonly ReferenceAttachmentRecord[],
196
196
  const flags = completenessFlags(card.editedSignals ?? card.signals);
197
197
  const thin = words !== null && words < teaserLine;
198
198
  const edited = Boolean(card.current?.textSortKey?.startsWith("curation-edit"));
199
- // A complete text an editor vouched for is not second-guessed by the signals.
200
- const doubtful = !edited && assessment !== "complete" && flags.length > 0 && !thin ? flags.some((flag) => /ends with|stops|no longer|not free|cut|declares/.test(flag)) : false;
199
+ // A complete text an editor vouched for is not second-guessed by the signals. A long text that merely ends without a full stop
200
+ // (a byline, a footnote, a list) is listed as a fact but is not on its own a reason to ask for a capture check.
201
+ const doubtful = !edited && assessment !== "complete" && flags.length > 0 && !thin ? flags.some((flag) => /ends with|stops with|no longer|not free|cut|declares/.test(flag)) : false;
201
202
  return { state: thin || doubtful ? "thin" : "full", words, estimated: false, source: "curation", curation: card.status, reason: null, flags, assessment, summaryStale, ...(thin ? { thinBy: "length" as const } : doubtful ? { thinBy: "signals" as const } : {}) };
202
203
  }
203
204
  if (primaryTextAttachment(attachments)) {
@@ -47,23 +47,31 @@ export async function publicHttpUrl(raw: string): Promise<URL | null> {
47
47
  }
48
48
  }
49
49
 
50
- /** Asks the page itself. Anything that fails (bad address, error status, timeout, too many redirects) counts as not frameable. */
51
- export async function checkSourceFrameable(raw: string, { timeoutMs = 5000, maxRedirects = 4 } = {}): Promise<boolean> {
50
+ /** GETs a public address, following up to `maxRedirects` redirects and checking every hop with publicHttpUrl. Null on any failure. */
51
+ export async function fetchPublic(raw: string, { accept, timeoutMs = 5000, maxRedirects = 4 }: { accept: string; timeoutMs?: number; maxRedirects?: number }): Promise<Response | null> {
52
52
  let next = await publicHttpUrl(raw);
53
53
  for (let hop = 0; next && hop <= maxRedirects; hop += 1) {
54
54
  let response: Response;
55
55
  try {
56
- response = await fetch(next, { method: "GET", redirect: "manual", signal: AbortSignal.timeout(timeoutMs), headers: { accept: "text/html" } });
56
+ response = await fetch(next, { method: "GET", redirect: "manual", signal: AbortSignal.timeout(timeoutMs), headers: { accept } });
57
57
  } catch {
58
- return false;
58
+ return null;
59
59
  }
60
- void response.body?.cancel();
61
60
  const location = response.headers.get("location");
62
61
  if (response.status >= 300 && response.status < 400 && location) {
62
+ void response.body?.cancel();
63
63
  next = await publicHttpUrl(new URL(location, next).toString());
64
64
  continue;
65
65
  }
66
- return response.ok && frameableFromHeaders(response.headers);
66
+ return response;
67
67
  }
68
- return false;
68
+ return null;
69
+ }
70
+
71
+ /** Asks the page itself. Anything that fails (bad address, error status, timeout, too many redirects) counts as not frameable. */
72
+ export async function checkSourceFrameable(raw: string, options: { timeoutMs?: number; maxRedirects?: number } = {}): Promise<boolean> {
73
+ const response = await fetchPublic(raw, { accept: "text/html", ...options });
74
+ if (!response) return false;
75
+ void response.body?.cancel();
76
+ return response.ok && frameableFromHeaders(response.headers);
69
77
  }