@anthusai/papyrus 5.49.0 → 5.51.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/app/api/newsroom/source-frameable/route.ts +12 -0
- package/app/api/newsroom/source-icon/route.ts +20 -0
- package/app/newsroom/[section]/[...selection]/page.tsx +2 -0
- package/app/newsroom/[section]/page.tsx +1 -0
- package/components/markdown-context.tsx +412 -0
- package/components/needs-me-bodies.tsx +3 -2
- package/components/needs-me-detail.tsx +2 -1
- package/components/needs-me-feed.tsx +36 -14
- package/components/needs-me-reference-detail.tsx +13 -15
- package/components/newsroom-access-gate.tsx +7 -16
- package/components/newsroom-ops-shell.tsx +0 -22
- package/components/newsroom-organizer.css +19 -0
- package/components/newsroom-references-view.tsx +19 -23
- package/components/reader-auth-control.tsx +3 -1
- package/components/reference-capture-panel.tsx +17 -7
- package/components/reference-feed.tsx +8 -4
- package/components/reference-review-card.tsx +27 -9
- package/components/reference-source-preview.tsx +27 -5
- package/components/review-rating-grid.tsx +20 -10
- package/components/topic-steering-workspace.tsx +3 -428
- package/components/ui/auto-grow-textarea.tsx +18 -0
- package/components/use-reference-ratings.ts +9 -4
- package/lib/newsroom-index-filters.ts +18 -0
- package/lib/newsroom-references.ts +25 -1
- package/lib/reference-capture.ts +3 -2
- package/lib/source-frameable.ts +77 -0
- package/lib/source-icon.ts +104 -0
- package/package.json +7 -2
- package/routes.manifest.json +23 -1
- package/components/newsroom-overview-skeleton.tsx +0 -25
|
@@ -13,6 +13,8 @@ import type { CardPrediction, ReviewChoice } from "./reference-review-card";
|
|
|
13
13
|
* Feedback is never colour alone: before you answer the predicted cell is neutral-accent with a check or cross; after, it turns
|
|
14
14
|
* green with a CircleCheck (Match) or red with a CircleX (Miss), and the confidence bar tints the same way. Your chosen button is
|
|
15
15
|
* filled. Flat tones and rounded shapes only, no borders.
|
|
16
|
+
* The grid sizes itself to its own width (a container query), not the window's: on a narrow card the label column shrinks to its
|
|
17
|
+
* word, the padding tightens, and the confidence wraps under "Accept" instead of spilling out of its cell.
|
|
16
18
|
*/
|
|
17
19
|
|
|
18
20
|
export type RatingFeedback = { phase: "idle" | "saving" | "saved" | "error"; message?: string };
|
|
@@ -81,9 +83,8 @@ export function RatingGrid({
|
|
|
81
83
|
return (
|
|
82
84
|
<div
|
|
83
85
|
className={cn(
|
|
84
|
-
"grid gap-x-2 gap-y-2",
|
|
85
|
-
showPrediction ? "grid-cols-[
|
|
86
|
-
showPrediction && size === "large" && "grid-cols-[5rem_minmax(0,1fr)_minmax(0,1fr)]",
|
|
86
|
+
"@container/rating grid gap-x-2 gap-y-2",
|
|
87
|
+
showPrediction ? "grid-cols-[auto_minmax(0,1fr)_minmax(0,1fr)] @max-[17rem]/rating:gap-x-1.5" : "grid-cols-2",
|
|
87
88
|
)}
|
|
88
89
|
data-rating-grid
|
|
89
90
|
>
|
|
@@ -95,7 +96,7 @@ export function RatingGrid({
|
|
|
95
96
|
return (
|
|
96
97
|
<span
|
|
97
98
|
className={cn(
|
|
98
|
-
"flex flex-col items-center justify-center rounded-[16px] px-2 py-1.5 text-center",
|
|
99
|
+
"flex min-w-0 flex-col items-center justify-center rounded-[16px] px-2 py-1.5 text-center @max-[17rem]/rating:px-1",
|
|
99
100
|
cell,
|
|
100
101
|
!predicted && "bg-[var(--feed-control)]/45 text-muted-foreground",
|
|
101
102
|
predicted && !verdict && "bg-[var(--feed-control)] font-semibold text-[var(--primary)]",
|
|
@@ -106,12 +107,21 @@ export function RatingGrid({
|
|
|
106
107
|
data-verdict={predicted && verdict ? verdict : undefined}
|
|
107
108
|
key={choice}
|
|
108
109
|
>
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
110
|
+
{/* Wraps rather than overflows: when the cell is narrow, the confidence moves onto its own line. */}
|
|
111
|
+
<span className="flex max-w-full flex-wrap items-center justify-center gap-x-1.5 leading-tight">
|
|
112
|
+
<span className="inline-flex items-center gap-1">
|
|
113
|
+
{Icon ? <Icon aria-hidden="true" className="size-4 shrink-0" /> : null}
|
|
114
|
+
{WORD[choice]}
|
|
115
|
+
</span>
|
|
116
|
+
{/* The confidence and the verdict share one line, so a vote never makes the cell (or the card) taller. */}
|
|
117
|
+
{predicted && (confidenceText || verdict) ? (
|
|
118
|
+
<small className="inline-flex items-center gap-1 whitespace-nowrap text-xs">
|
|
119
|
+
{confidenceText ? <span className="font-normal opacity-80" data-cyclotron-confidence>{confidenceText}</span> : null}
|
|
120
|
+
{confidenceText && verdict ? <span aria-hidden="true" className="opacity-60">·</span> : null}
|
|
121
|
+
{verdict ? <span className="font-medium" data-verdict-word>{verdict === "match" ? "Match" : "Miss"}</span> : null}
|
|
122
|
+
</small>
|
|
123
|
+
) : null}
|
|
113
124
|
</span>
|
|
114
|
-
{predicted && verdict ? <small className="text-xs font-medium" data-verdict-word>{verdict === "match" ? "Match" : "Miss"}</small> : null}
|
|
115
125
|
{predicted && verdict ? <span className="sr-only">{verdict === "match" ? ", matches your answer" : ", differs from your answer"}</span> : null}
|
|
116
126
|
</span>
|
|
117
127
|
);
|
|
@@ -151,7 +161,7 @@ export function RatingGrid({
|
|
|
151
161
|
aria-disabled={disabled ? true : undefined}
|
|
152
162
|
aria-pressed={on}
|
|
153
163
|
className={cn(
|
|
154
|
-
"inline-flex items-center justify-center gap-1.5 rounded-[16px] px-2 font-medium outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring",
|
|
164
|
+
"inline-flex min-w-0 items-center justify-center gap-1.5 rounded-[16px] px-2 font-medium outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring @max-[17rem]/rating:gap-1 @max-[17rem]/rating:px-1",
|
|
155
165
|
cell,
|
|
156
166
|
on ? "bg-[var(--primary)] font-semibold text-[var(--primary-foreground)]" : "bg-[var(--feed-control)] text-foreground hover:bg-[var(--feed-control)]/70",
|
|
157
167
|
disabled && !on && "opacity-60",
|
|
@@ -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 {
|
|
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);
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useLayoutEffect, useRef, type TextareaHTMLAttributes } from "react";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A textarea as tall as its text. It never scrolls inside itself, so on a phone a swipe over it scrolls the page instead of
|
|
7
|
+
* getting caught in the box. (CSS `field-sizing: content` does the same where supported; the height is set here for the rest.)
|
|
8
|
+
*/
|
|
9
|
+
export function AutoGrowTextarea({ className, style, value, ...props }: TextareaHTMLAttributes<HTMLTextAreaElement>) {
|
|
10
|
+
const ref = useRef<HTMLTextAreaElement>(null);
|
|
11
|
+
useLayoutEffect(() => {
|
|
12
|
+
const element = ref.current;
|
|
13
|
+
if (!element) return;
|
|
14
|
+
element.style.height = "auto";
|
|
15
|
+
element.style.height = `${element.scrollHeight}px`;
|
|
16
|
+
}, [value]);
|
|
17
|
+
return <textarea {...props} className={className} ref={ref} rows={props.rows ?? 2} style={{ ...style, overflow: "hidden", resize: "none" }} value={value} />;
|
|
18
|
+
}
|
|
@@ -110,7 +110,9 @@ export function useRatingStore({ save, saved, seed, needsReason, sticky, saveNot
|
|
|
110
110
|
|
|
111
111
|
const commit = useCallback(async (id: string, choice: ReviewChoice) => {
|
|
112
112
|
if (finalDecision(id)) { void saveNoteOnly(id); return; }
|
|
113
|
-
const { note, reasonCode } = values(id);
|
|
113
|
+
const { note, reasonCode: chosenCode } = values(id);
|
|
114
|
+
// A typed reason is a reason: a Reject with words but no reason button is filed under "other" rather than left waiting.
|
|
115
|
+
const reasonCode = chosenCode ?? (choice === "reject" && note.trim() ? "other" : null);
|
|
114
116
|
if (choice === "reject" && !reasonCode && (latest.current.needsReason?.(id) ?? true)) { patch(id, { pending: "reject", askReason: true }); return; }
|
|
115
117
|
if (inFlight.current.has(id)) { dirty.current.add(id); return; }
|
|
116
118
|
inFlight.current.add(id);
|
|
@@ -120,16 +122,19 @@ export function useRatingStore({ save, saved, seed, needsReason, sticky, saveNot
|
|
|
120
122
|
try {
|
|
121
123
|
await latest.current.save(id, choice, { note: note.trim(), reasonCode: choice === "reject" ? reasonCode : null });
|
|
122
124
|
if (latest.current.sticky?.(id)) decidedHere.current.add(id);
|
|
123
|
-
// What was saved now follows the record again (another editor's update shows); anything typed meanwhile is kept.
|
|
125
|
+
// What was saved now follows the record again (another editor's update shows); anything typed meanwhile is kept. While another
|
|
126
|
+
// save is due (changed mid-save), the store keeps everything: the record may not show this save yet, and the next one sends it.
|
|
124
127
|
const now = itemsRef.current[id] ?? EMPTY;
|
|
125
|
-
|
|
128
|
+
const follow = !dirty.current.has(id);
|
|
129
|
+
patch(id, { pending: null, feedback: { phase: "saved" }, ...(follow && now.note === started.note ? { note: undefined } : {}), ...(follow && now.reasonCode === started.reasonCode ? { reasonCode: undefined } : {}) });
|
|
126
130
|
} catch (failure) {
|
|
127
131
|
// The typed reason stays in the store; only the vote is undone.
|
|
128
132
|
patch(id, { pending: null, feedback: { phase: "error", message: failure instanceof Error ? failure.message : "Your answer could not be saved. Tap it again to retry." } });
|
|
129
133
|
} finally {
|
|
130
134
|
inFlight.current.delete(id);
|
|
131
135
|
}
|
|
132
|
-
|
|
136
|
+
// Changed while saving (a reason chip tapped mid-save): save again. The record may not show the vote yet, so fall back to it.
|
|
137
|
+
if (dirty.current.has(id)) { dirty.current.delete(id); const again = itemsRef.current[id]?.pending ?? latest.current.saved(id) ?? choice; if (again) void commit(id, again); }
|
|
133
138
|
}, [patch, saveNoteOnly]);
|
|
134
139
|
|
|
135
140
|
const effective = (id: string): ReviewChoice | null => itemsRef.current[id]?.pending ?? latest.current.saved(id);
|
|
@@ -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
|
-
|
|
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 {
|
package/lib/reference-capture.ts
CHANGED
|
@@ -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
|
-
|
|
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)) {
|