@startsimpli/ui 0.4.142 → 0.4.144
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/package.json +38 -12
- package/src/components/blog/MarkdownRenderer.tsx +23 -5
- package/src/components/track-changes/ChangesRail.tsx +687 -0
- package/src/components/track-changes/TrackedField.tsx +293 -0
- package/src/components/track-changes/TrackedReadView.tsx +273 -0
- package/src/components/track-changes/TrackedTextEditor.tsx +595 -0
- package/src/components/track-changes/__tests__/extension.test.ts +350 -0
- package/src/components/track-changes/__tests__/model.test.ts +461 -0
- package/src/components/track-changes/__tests__/rails.test.tsx +604 -0
- package/src/components/track-changes/__tests__/tracked-text-editor.test.tsx +476 -0
- package/src/components/track-changes/describe.ts +76 -0
- package/src/components/track-changes/extension.ts +724 -0
- package/src/components/track-changes/final-text.ts +54 -0
- package/src/components/track-changes/http-client.ts +109 -0
- package/src/components/track-changes/index.ts +90 -0
- package/src/components/track-changes/normalize.ts +393 -0
- package/src/components/track-changes/read-marks.ts +262 -0
- package/src/components/track-changes/types.ts +451 -0
- package/src/components/track-changes/useTrackChanges.ts +407 -0
- package/src/components/track-changes/word-diff.ts +97 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The FINAL and ORIGINAL views (bd startsim-whwxd.3; design startsim-xyn25
|
|
3
|
+
* section d).
|
|
4
|
+
*
|
|
5
|
+
* Read literally, the design's two views are the same text: the stored string
|
|
6
|
+
* never has a suggestion applied, so "the current text" and "the base with no
|
|
7
|
+
* suggestion applied" are one string. They follow Word's meaning instead:
|
|
8
|
+
*
|
|
9
|
+
* ORIGINAL the stored text, with no marks.
|
|
10
|
+
* FINAL the stored text with every OPEN, PLACED suggestion applied,
|
|
11
|
+
* with no marks: what the record would read if all were accepted.
|
|
12
|
+
*
|
|
13
|
+
* Two suggestions that overlap cannot both be applied, so the later one (in
|
|
14
|
+
* document order) is skipped and NAMED, never merged by guesswork. The server
|
|
15
|
+
* is the one that applies a real accept; this is a preview only.
|
|
16
|
+
*/
|
|
17
|
+
import type { Suggestion } from './types';
|
|
18
|
+
|
|
19
|
+
export interface FinalText {
|
|
20
|
+
text: string;
|
|
21
|
+
/** Applied, in document order. */
|
|
22
|
+
applied: string[];
|
|
23
|
+
/** Open and placed, but overlapping one already applied. */
|
|
24
|
+
skipped: string[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function placedOpen(suggestions: Suggestion[]): Suggestion[] {
|
|
28
|
+
return suggestions
|
|
29
|
+
.filter((s) => s.status === 'open' && s.anchorState !== 'orphaned')
|
|
30
|
+
.slice()
|
|
31
|
+
.sort((a, b) => a.from - b.from || a.to - b.to);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function applyOpenSuggestions(text: string, suggestions: Suggestion[]): FinalText {
|
|
35
|
+
const applied: string[] = [];
|
|
36
|
+
const skipped: string[] = [];
|
|
37
|
+
let out = '';
|
|
38
|
+
let last = 0;
|
|
39
|
+
let lastInsertAt = -1;
|
|
40
|
+
for (const s of placedOpen(suggestions)) {
|
|
41
|
+
const overlaps = s.from < last || (s.from === s.to && s.from === lastInsertAt);
|
|
42
|
+
// The quote must still be what the text holds there; normalize already
|
|
43
|
+
// orphans a mismatch, so this is the belt to its braces.
|
|
44
|
+
if (overlaps || s.to > text.length || text.slice(s.from, s.to) !== s.quote) {
|
|
45
|
+
skipped.push(s.id);
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
out += text.slice(last, s.from) + s.replacement;
|
|
49
|
+
last = s.to;
|
|
50
|
+
lastInsertAt = s.from === s.to ? s.from : -1;
|
|
51
|
+
applied.push(s.id);
|
|
52
|
+
}
|
|
53
|
+
return { text: out + text.slice(last), applied, skipped };
|
|
54
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A {@link TrackChangesClient} over plain `fetch`, for any foundry app whose
|
|
3
|
+
* tenant API serves the track-changes routes (start-simpli-api #507/#508;
|
|
4
|
+
* bd startsim-whwxd.3).
|
|
5
|
+
*
|
|
6
|
+
* PLAIN FETCH ON PURPOSE. The shared API client camelCases response keys
|
|
7
|
+
* recursively, and these responses carry tenant-declared field names
|
|
8
|
+
* (`field: "judge_verdict"`) that must arrive as declared. The normalizers
|
|
9
|
+
* read either spelling of their own keys, so raw snake_case is what they want.
|
|
10
|
+
*
|
|
11
|
+
* The host supplies the auth header and the URL shape (an app whose proxy
|
|
12
|
+
* appends a trailing slash passes paths without one, and vice versa).
|
|
13
|
+
*
|
|
14
|
+
* A non-2xx answer throws {@link TrackChangesHttpError} carrying `status` and
|
|
15
|
+
* the parsed `body`, which is what the hook reads to say WHY (a 404 is "you
|
|
16
|
+
* may not read this", never "there is nothing here").
|
|
17
|
+
*/
|
|
18
|
+
import type {
|
|
19
|
+
AcceptRequest,
|
|
20
|
+
AcceptResponseWire,
|
|
21
|
+
BlameResponseWire,
|
|
22
|
+
CommentPageWire,
|
|
23
|
+
CommentRequest,
|
|
24
|
+
CommentThreadWire,
|
|
25
|
+
CommentWire,
|
|
26
|
+
ProposeRequest,
|
|
27
|
+
RosterPageWire,
|
|
28
|
+
SuggestionPageWire,
|
|
29
|
+
TrackChangesClient,
|
|
30
|
+
} from './types';
|
|
31
|
+
|
|
32
|
+
export class TrackChangesHttpError extends Error {
|
|
33
|
+
readonly status: number;
|
|
34
|
+
readonly body: Record<string, unknown>;
|
|
35
|
+
constructor(status: number, body: Record<string, unknown>) {
|
|
36
|
+
const d = typeof body.detail === 'string' ? body.detail : `the server answered ${status}`;
|
|
37
|
+
super(d);
|
|
38
|
+
this.name = 'TrackChangesHttpError';
|
|
39
|
+
this.status = status;
|
|
40
|
+
this.body = body;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface HttpTrackChangesOptions {
|
|
45
|
+
/** Request headers, normally `{ authorization: 'Bearer …' }`. */
|
|
46
|
+
headers: () => Promise<Record<string, string>> | Record<string, string>;
|
|
47
|
+
/** Turn an API path (no leading host, no trailing slash, e.g.
|
|
48
|
+
* `/api/v1/entities/<id>/blame`) into the URL to fetch. Default: the path
|
|
49
|
+
* with a trailing slash, which is what Django routes. */
|
|
50
|
+
url?: (path: string) => string;
|
|
51
|
+
fetch?: typeof fetch;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function parse(res: Response): Promise<Record<string, unknown>> {
|
|
55
|
+
try {
|
|
56
|
+
const j = await res.json();
|
|
57
|
+
return j && typeof j === 'object' ? (j as Record<string, unknown>) : {};
|
|
58
|
+
} catch {
|
|
59
|
+
return {};
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function createHttpTrackChangesClient(
|
|
64
|
+
recordId: string | number,
|
|
65
|
+
{ headers, url = (p) => `${p}/`, fetch: f }: HttpTrackChangesOptions,
|
|
66
|
+
): TrackChangesClient {
|
|
67
|
+
const doFetch = f ?? ((...a: Parameters<typeof fetch>) => fetch(...a));
|
|
68
|
+
const base = `/api/v1/entities/${recordId}`;
|
|
69
|
+
|
|
70
|
+
async function call<T>(path: string, init?: { method?: string; body?: unknown; query?: Record<string, string | undefined> }): Promise<T> {
|
|
71
|
+
const q = new URLSearchParams();
|
|
72
|
+
for (const [k, v] of Object.entries(init?.query ?? {})) if (v != null && v !== '') q.set(k, v);
|
|
73
|
+
const qs = q.toString();
|
|
74
|
+
const h = { ...(await headers()) };
|
|
75
|
+
if (init?.body !== undefined) h['content-type'] = 'application/json';
|
|
76
|
+
const res = await doFetch(`${url(path)}${qs ? `?${qs}` : ''}`, {
|
|
77
|
+
method: init?.method ?? 'GET',
|
|
78
|
+
headers: h,
|
|
79
|
+
...(init?.body !== undefined ? { body: JSON.stringify(init.body) } : {}),
|
|
80
|
+
});
|
|
81
|
+
const body = await parse(res);
|
|
82
|
+
if (!res.ok) throw new TrackChangesHttpError(res.status, body);
|
|
83
|
+
return body as T;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
blame: ({ field }) => call<BlameResponseWire>(`${base}/blame`, { query: { field } }),
|
|
88
|
+
listSuggestions: ({ field, cursor }) =>
|
|
89
|
+
call<SuggestionPageWire>(`${base}/suggestions`, { query: { field, cursor } }),
|
|
90
|
+
listComments: ({ field, cursor, status }) =>
|
|
91
|
+
call<CommentPageWire>(`${base}/comments`, { query: { field, cursor, status } }),
|
|
92
|
+
propose: (body: ProposeRequest) => call(`${base}/suggestions`, { method: 'POST', body }),
|
|
93
|
+
accept: ({ ids, expectedVersion }: AcceptRequest) =>
|
|
94
|
+
call<AcceptResponseWire>(`${base}/suggestions/accept`, {
|
|
95
|
+
method: 'POST',
|
|
96
|
+
// ONE spelling of the precondition: the server refuses if either
|
|
97
|
+
// disagrees, so sending If-Match as well can only add a way to fail.
|
|
98
|
+
body: { ids, expected_version: expectedVersion },
|
|
99
|
+
}),
|
|
100
|
+
reject: ({ ids, reason }) =>
|
|
101
|
+
call(`${base}/suggestions/reject`, { method: 'POST', body: reason ? { ids, reason } : { ids } }),
|
|
102
|
+
withdraw: (id) => call(`${base}/suggestions/${id}/withdraw`, { method: 'POST', body: {} }),
|
|
103
|
+
comment: (req: CommentRequest) => call<CommentThreadWire>(`${base}/comments`, { method: 'POST', body: req }),
|
|
104
|
+
reply: (threadId, req) => call<CommentWire>(`${base}/comments/${threadId}/replies`, { method: 'POST', body: req }),
|
|
105
|
+
resolveThread: (threadId) => call(`${base}/comments/${threadId}/resolve`, { method: 'POST', body: {} }),
|
|
106
|
+
reopenThread: (threadId) => call(`${base}/comments/${threadId}/reopen`, { method: 'POST', body: {} }),
|
|
107
|
+
roster: ({ cursor }) => call<RosterPageWire>(`/api/v1/org/roster`, { query: { cursor } }),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @startsimpli/ui/track-changes — the track-changes editor surface
|
|
3
|
+
* (bd startsim-sjuai; design startsim-xyn25; epic startsim-whwxd).
|
|
4
|
+
*
|
|
5
|
+
* A SEPARATE SUBPATH ON PURPOSE, and never re-exported from the root barrel:
|
|
6
|
+
* it imports CodeMirror, which is an OPTIONAL peer dependency. An app that
|
|
7
|
+
* never mounts the editor must not need CodeMirror installed, and one that
|
|
8
|
+
* does should load it with a dynamic import on the page that edits.
|
|
9
|
+
*
|
|
10
|
+
* Install in the consuming app:
|
|
11
|
+
* @codemirror/state @codemirror/view @codemirror/commands
|
|
12
|
+
* @codemirror/language @codemirror/lang-markdown
|
|
13
|
+
*
|
|
14
|
+
* Wiring, in the host:
|
|
15
|
+
*
|
|
16
|
+
* ```tsx
|
|
17
|
+
* const tc = useTrackChanges({ client, field: 'blog', value, version });
|
|
18
|
+
* <TrackedTextEditor
|
|
19
|
+
* label="Blog (markdown)" value={value} version={version}
|
|
20
|
+
* currentActorSub={me.sub} canEdit={canEdit}
|
|
21
|
+
* blame={tc.blame} suggestions={tc.suggestions} threads={tc.threads}
|
|
22
|
+
* notices={tc.notices} onPropose={tc.propose}
|
|
23
|
+
* onChange={save} // editing mode only; never called while suggesting
|
|
24
|
+
* />
|
|
25
|
+
* ```
|
|
26
|
+
*/
|
|
27
|
+
export { TrackedTextEditor } from './TrackedTextEditor';
|
|
28
|
+
export type { TrackMode, TrackedTextEditorHandle, TrackedTextEditorProps } from './TrackedTextEditor';
|
|
29
|
+
|
|
30
|
+
export { useTrackChanges, TrackChangesActionError } from './useTrackChanges';
|
|
31
|
+
export type { SourceState, SourceStatus, TrackChanges, TrackChangesActions, UseTrackChangesOptions } from './useTrackChanges';
|
|
32
|
+
|
|
33
|
+
// The rail, the Read view, the views, and the composer over all of them
|
|
34
|
+
// (startsim-whwxd.3).
|
|
35
|
+
export { TrackedField } from './TrackedField';
|
|
36
|
+
export type { TrackedFieldHandle, TrackedFieldProps, TrackedFieldSurface, TrackedFieldView } from './TrackedField';
|
|
37
|
+
export { ChangesRail, mentionQuery, mentionsIn } from './ChangesRail';
|
|
38
|
+
export type { ChangesRailProps } from './ChangesRail';
|
|
39
|
+
export { TrackedReadView, READ_STYLE, authorTint, readMarksOf, renderHast } from './TrackedReadView';
|
|
40
|
+
export type { TrackedReadViewProps } from './TrackedReadView';
|
|
41
|
+
export { alignToSource, markTree, plainTree, rehypeTrackMarks, sourceOffsetOf } from './read-marks';
|
|
42
|
+
export type { ReadMark, ReadMarkKind } from './read-marks';
|
|
43
|
+
export { applyOpenSuggestions, placedOpen } from './final-text';
|
|
44
|
+
export type { FinalText } from './final-text';
|
|
45
|
+
export { createHttpTrackChangesClient, TrackChangesHttpError } from './http-client';
|
|
46
|
+
export type { HttpTrackChangesOptions } from './http-client';
|
|
47
|
+
|
|
48
|
+
export {
|
|
49
|
+
DEFAULT_KEYS,
|
|
50
|
+
LINE_SEPARATOR,
|
|
51
|
+
activeMarkOf,
|
|
52
|
+
computeMarks,
|
|
53
|
+
enterSuggesting,
|
|
54
|
+
focusMark,
|
|
55
|
+
isAuthorship,
|
|
56
|
+
isChangeOrComment,
|
|
57
|
+
isSuggesting,
|
|
58
|
+
leaveSuggesting,
|
|
59
|
+
marksOf,
|
|
60
|
+
moveToMark,
|
|
61
|
+
ownSuggestions,
|
|
62
|
+
proposedText,
|
|
63
|
+
rebaseOnto,
|
|
64
|
+
selectionAnchorOf,
|
|
65
|
+
setAnnotations,
|
|
66
|
+
trackChanges,
|
|
67
|
+
trackingOf,
|
|
68
|
+
} from './extension';
|
|
69
|
+
export type { AnnotationsInput, MarkKind, MarkVisibility, TrackKeys, TrackMark, Tracking } from './extension';
|
|
70
|
+
|
|
71
|
+
export {
|
|
72
|
+
ANCHOR_CONTEXT,
|
|
73
|
+
anchorFor,
|
|
74
|
+
blameNotice,
|
|
75
|
+
BLAME_REASON_TEXT,
|
|
76
|
+
codePointToUtf16,
|
|
77
|
+
normalizeActor,
|
|
78
|
+
normalizeBlame,
|
|
79
|
+
normalizeRoster,
|
|
80
|
+
normalizeSuggestions,
|
|
81
|
+
normalizeThreads,
|
|
82
|
+
toServerOffsets,
|
|
83
|
+
utf16ToCodePoint,
|
|
84
|
+
} from './normalize';
|
|
85
|
+
|
|
86
|
+
export { describeBlame, describeSuggestion, describeThread } from './describe';
|
|
87
|
+
export { applyTextChanges, tokenize, wordChanges } from './word-diff';
|
|
88
|
+
export type { TextChange } from './word-diff';
|
|
89
|
+
|
|
90
|
+
export type * from './types';
|
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wire → editor shapes for the track-changes surface (bd startsim-sjuai).
|
|
3
|
+
*
|
|
4
|
+
* Three jobs, each the kind of thing that fails silently if done per caller:
|
|
5
|
+
*
|
|
6
|
+
* 1. READ BOTH SPELLINGS. The shared API client camelCases every response key,
|
|
7
|
+
* so `actor_kind` may arrive as `actorKind` (see history/types.ts).
|
|
8
|
+
* 2. CONVERT OFFSETS. The server counts code points, the editor counts UTF-16
|
|
9
|
+
* code units (see types.ts). Converted here, once, against the text the
|
|
10
|
+
* offsets describe.
|
|
11
|
+
* 3. NEVER PAINT A GUESS. An anchor whose quote does not match the text at its
|
|
12
|
+
* converted offsets is reported `orphaned`, and a span outside the text is
|
|
13
|
+
* clipped or dropped. Painting a comment on the wrong words is worse than
|
|
14
|
+
* listing it as orphaned, which is what the design asks for anyway.
|
|
15
|
+
*/
|
|
16
|
+
import type { ActorKind, ActorKindSource } from '../ActorIdentity';
|
|
17
|
+
import type {
|
|
18
|
+
AnchorState,
|
|
19
|
+
AnchorWire,
|
|
20
|
+
BlameReason,
|
|
21
|
+
BlameResponseWire,
|
|
22
|
+
BlameSpan,
|
|
23
|
+
BlameSpanWire,
|
|
24
|
+
BlameVia,
|
|
25
|
+
CommentPageWire,
|
|
26
|
+
CommentThread,
|
|
27
|
+
CommentThreadWire,
|
|
28
|
+
OffsetUnit,
|
|
29
|
+
SelectionAnchor,
|
|
30
|
+
Suggestion,
|
|
31
|
+
SuggestionPageWire,
|
|
32
|
+
SuggestionStatus,
|
|
33
|
+
SuggestionWire,
|
|
34
|
+
PositionWire,
|
|
35
|
+
RosterEntryWire,
|
|
36
|
+
RosterMember,
|
|
37
|
+
RosterPageWire,
|
|
38
|
+
TrackActor,
|
|
39
|
+
WireActor,
|
|
40
|
+
WireAuthor,
|
|
41
|
+
} from './types';
|
|
42
|
+
|
|
43
|
+
/* -------------------------------------------------------------------------- */
|
|
44
|
+
/* Offsets */
|
|
45
|
+
/* -------------------------------------------------------------------------- */
|
|
46
|
+
|
|
47
|
+
const ASTRAL = /[\uD800-\uDBFF][\uDC00-\uDFFF]/;
|
|
48
|
+
|
|
49
|
+
/** UTF-16 offset of the `cp`-th code point of `text`. Clamped to the text. */
|
|
50
|
+
export function codePointToUtf16(text: string, cp: number): number {
|
|
51
|
+
if (!(cp > 0)) return 0;
|
|
52
|
+
if (!ASTRAL.test(text)) return Math.min(cp, text.length);
|
|
53
|
+
let units = 0;
|
|
54
|
+
let points = 0;
|
|
55
|
+
while (units < text.length && points < cp) {
|
|
56
|
+
const c = text.charCodeAt(units);
|
|
57
|
+
units += c >= 0xd800 && c <= 0xdbff && units + 1 < text.length ? 2 : 1;
|
|
58
|
+
points++;
|
|
59
|
+
}
|
|
60
|
+
return units;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Code-point offset of UTF-16 offset `u` in `text`. */
|
|
64
|
+
export function utf16ToCodePoint(text: string, u: number): number {
|
|
65
|
+
if (!(u > 0)) return 0;
|
|
66
|
+
const end = Math.min(u, text.length);
|
|
67
|
+
if (!ASTRAL.test(text)) return end;
|
|
68
|
+
return Array.from(text.slice(0, end)).length;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** An editor range in the server's unit, for posting an anchor. */
|
|
72
|
+
export function toServerOffsets(
|
|
73
|
+
text: string,
|
|
74
|
+
range: { from: number; to: number },
|
|
75
|
+
unit: OffsetUnit = 'code_point',
|
|
76
|
+
): { from: number; to: number } {
|
|
77
|
+
if (unit === 'utf16') return { from: range.from, to: range.to };
|
|
78
|
+
return { from: utf16ToCodePoint(text, range.from), to: utf16ToCodePoint(text, range.to) };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The design's anchor (section c): the range, its text, and 32 characters of
|
|
82
|
+
* context each side for the server's quote fallback. UTF-16 offsets. */
|
|
83
|
+
export const ANCHOR_CONTEXT = 32;
|
|
84
|
+
|
|
85
|
+
export function anchorFor(text: string, a: number, b: number): SelectionAnchor {
|
|
86
|
+
const from = Math.max(0, Math.min(a, b, text.length));
|
|
87
|
+
const to = Math.min(text.length, Math.max(a, b, 0));
|
|
88
|
+
return {
|
|
89
|
+
from,
|
|
90
|
+
to,
|
|
91
|
+
quote: text.slice(from, to),
|
|
92
|
+
prefix: text.slice(Math.max(0, from - ANCHOR_CONTEXT), from),
|
|
93
|
+
suffix: text.slice(to, to + ANCHOR_CONTEXT),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/* -------------------------------------------------------------------------- */
|
|
98
|
+
/* Actors */
|
|
99
|
+
/* -------------------------------------------------------------------------- */
|
|
100
|
+
|
|
101
|
+
const str = (v: unknown): string => (typeof v === 'string' ? v : '');
|
|
102
|
+
|
|
103
|
+
function kindOf(v: unknown): ActorKind {
|
|
104
|
+
return v === 'person' || v === 'machine' ? v : 'unknown';
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function kindSourceOf(v: unknown): ActorKindSource | undefined {
|
|
108
|
+
return v === 'claim' || v === 'inferred' ? v : undefined;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function isNested(w: object): w is WireAuthor {
|
|
112
|
+
return 'sub' in w || 'label' in w || 'kind' in w;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Reads flat actor columns (`actor_label`), a nested author object
|
|
116
|
+
* (`{sub, label, kind, kind_source}`), or a bare label. */
|
|
117
|
+
export function normalizeActor(w: WireActor | WireAuthor | string | null | undefined): TrackActor {
|
|
118
|
+
if (typeof w === 'string') return { sub: '', label: w, kind: 'unknown' };
|
|
119
|
+
if (w && isNested(w)) {
|
|
120
|
+
return {
|
|
121
|
+
sub: str(w.sub),
|
|
122
|
+
label: str(w.label).trim(),
|
|
123
|
+
kind: kindOf(w.kind),
|
|
124
|
+
kindSource: kindSourceOf(w.kind_source ?? w.kindSource),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
w = w as WireActor | null | undefined;
|
|
128
|
+
return {
|
|
129
|
+
sub: str(w?.actor_sub ?? w?.actorSub),
|
|
130
|
+
label: str(w?.actor_label ?? w?.actorLabel).trim(),
|
|
131
|
+
kind: kindOf(w?.actor_kind ?? w?.actorKind),
|
|
132
|
+
kindSource: kindSourceOf(w?.actor_kind_source ?? w?.actorKindSource),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/* -------------------------------------------------------------------------- */
|
|
137
|
+
/* Ranges */
|
|
138
|
+
/* -------------------------------------------------------------------------- */
|
|
139
|
+
|
|
140
|
+
function unitOf(w: { offset_unit?: OffsetUnit | null; offsetUnit?: OffsetUnit | null } | null | undefined): OffsetUnit {
|
|
141
|
+
return (w?.offset_unit ?? w?.offsetUnit) === 'utf16' ? 'utf16' : 'code_point';
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function convert(text: string, n: number, unit: OffsetUnit): number {
|
|
145
|
+
if (unit === 'utf16') return Math.max(0, Math.min(Math.trunc(n), text.length));
|
|
146
|
+
return codePointToUtf16(text, Math.trunc(n));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function isNum(v: unknown): v is number {
|
|
150
|
+
return typeof v === 'number' && Number.isFinite(v);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const ANCHOR_STATES: AnchorState[] = ['mapped', 'reanchored', 'orphaned'];
|
|
154
|
+
|
|
155
|
+
function anchorStateOf(v: unknown): AnchorState {
|
|
156
|
+
return ANCHOR_STATES.includes(v as AnchorState) ? (v as AnchorState) : 'mapped';
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Resolve an anchor to UTF-16 offsets in `text`, or report it orphaned. */
|
|
160
|
+
function resolveAnchor(
|
|
161
|
+
text: string,
|
|
162
|
+
anchor: AnchorWire | null | undefined,
|
|
163
|
+
declared: AnchorState,
|
|
164
|
+
unit: OffsetUnit,
|
|
165
|
+
): { from: number | null; to: number | null; quote: string; state: AnchorState } {
|
|
166
|
+
const quote = str(anchor?.quote);
|
|
167
|
+
if (!anchor || declared === 'orphaned' || !isNum(anchor.from) || !isNum(anchor.to)) {
|
|
168
|
+
return { from: null, to: null, quote, state: anchor ? 'orphaned' : declared };
|
|
169
|
+
}
|
|
170
|
+
const from = convert(text, anchor.from, unit);
|
|
171
|
+
const to = convert(text, anchor.to, unit);
|
|
172
|
+
// An anchor is never clamped: past the end means it describes other text.
|
|
173
|
+
const max = unit === 'utf16' ? text.length : utf16ToCodePoint(text, text.length);
|
|
174
|
+
if (anchor.from < 0 || anchor.to < anchor.from || anchor.to > max) {
|
|
175
|
+
return { from: null, to: null, quote, state: 'orphaned' };
|
|
176
|
+
}
|
|
177
|
+
// A quote that does not match is a disagreement about the text, so the
|
|
178
|
+
// offsets mean nothing. `quote` absent (null) is not checked: a server that
|
|
179
|
+
// withheld it under the field gate still placed the range.
|
|
180
|
+
if (anchor.quote != null && text.slice(from, to) !== quote) {
|
|
181
|
+
return { from: null, to: null, quote, state: 'orphaned' };
|
|
182
|
+
}
|
|
183
|
+
// A pure insert has no quote, so the check above passes for ANY offset. Its
|
|
184
|
+
// neighbours are checked instead: the nearest INSERT_CONTEXT characters of
|
|
185
|
+
// prefix and suffix. Only when BOTH sides disagree is it orphaned, because
|
|
186
|
+
// an edit a few words away legitimately changes one side's wider context
|
|
187
|
+
// while the server still (correctly) reports the insert point as mapped.
|
|
188
|
+
if (from === to && !contextFits(text, from, anchor.prefix, anchor.suffix)) {
|
|
189
|
+
return { from: null, to: null, quote, state: 'orphaned' };
|
|
190
|
+
}
|
|
191
|
+
return { from, to, quote: anchor.quote != null ? quote : text.slice(from, to), state: declared };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const INSERT_CONTEXT = 8;
|
|
195
|
+
|
|
196
|
+
function contextFits(text: string, at: number, prefix?: string | null, suffix?: string | null): boolean {
|
|
197
|
+
const before = prefix ? prefix.slice(-INSERT_CONTEXT) : '';
|
|
198
|
+
const after = suffix ? suffix.slice(0, INSERT_CONTEXT) : '';
|
|
199
|
+
if (!before && !after) return true; // nothing to check against (start of an empty field)
|
|
200
|
+
const beforeOk = !!before && text.slice(Math.max(0, at - before.length), at) === before;
|
|
201
|
+
const afterOk = !!after && text.slice(at, at + after.length) === after;
|
|
202
|
+
return beforeOk || afterOk;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** The anchor to place and its declared state: `position` (the contract)
|
|
206
|
+
* first, then the older spellings. A position's quote is the anchor's. */
|
|
207
|
+
function placement(
|
|
208
|
+
w: {
|
|
209
|
+
position?: PositionWire | null;
|
|
210
|
+
mapped_anchor?: AnchorWire | null;
|
|
211
|
+
mappedAnchor?: AnchorWire | null;
|
|
212
|
+
current?: AnchorWire | null;
|
|
213
|
+
anchor?: AnchorWire | null;
|
|
214
|
+
anchor_status?: string | null;
|
|
215
|
+
anchorStatus?: string | null;
|
|
216
|
+
},
|
|
217
|
+
): { anchor: AnchorWire | null | undefined; state: AnchorState } {
|
|
218
|
+
const pos = w.position;
|
|
219
|
+
// An explicit null: the server placed nothing (a suggestion that is no
|
|
220
|
+
// longer open, or a record/field comment). Not placed, and not orphaned.
|
|
221
|
+
if (pos === null) return { anchor: null, state: 'mapped' };
|
|
222
|
+
if (pos) {
|
|
223
|
+
const state = anchorStateOf(pos.state);
|
|
224
|
+
if (!isNum(pos.from) || !isNum(pos.to)) {
|
|
225
|
+
return { anchor: { from: NaN, to: NaN, quote: w.anchor?.quote ?? null }, state: 'orphaned' };
|
|
226
|
+
}
|
|
227
|
+
return {
|
|
228
|
+
anchor: { from: pos.from, to: pos.to, quote: w.anchor?.quote ?? null, prefix: w.anchor?.prefix, suffix: w.anchor?.suffix },
|
|
229
|
+
state,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
return {
|
|
233
|
+
anchor: w.mapped_anchor ?? w.mappedAnchor ?? w.current ?? w.anchor,
|
|
234
|
+
state: anchorStateOf(w.anchor_status ?? w.anchorStatus),
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function results<T>(w: { results?: T[] | null } | T[] | null | undefined): T[] {
|
|
239
|
+
if (Array.isArray(w)) return w;
|
|
240
|
+
return w?.results ?? [];
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/* -------------------------------------------------------------------------- */
|
|
244
|
+
/* Blame */
|
|
245
|
+
/* -------------------------------------------------------------------------- */
|
|
246
|
+
|
|
247
|
+
const VIAS: BlameVia[] = ['edit', 'accepted_suggestion', 'restore', 'unknown'];
|
|
248
|
+
|
|
249
|
+
/** What each reason code means, for when the server sends no `detail`. */
|
|
250
|
+
export const BLAME_REASON_TEXT: Record<BlameReason, string> = {
|
|
251
|
+
before_history: 'written before the history began',
|
|
252
|
+
truncated: 'a revision was too large to store in full',
|
|
253
|
+
values_omitted: 'a revision was too large to store its values',
|
|
254
|
+
chain_break: 'the history has a gap here',
|
|
255
|
+
unrecorded_change: 'changed without a recorded revision',
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
function reasonOf(v: unknown): BlameReason | null {
|
|
259
|
+
return typeof v === 'string' && v in BLAME_REASON_TEXT ? (v as BlameReason) : null;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export function normalizeBlame(w: BlameResponseWire | null | undefined, text: string): BlameSpan[] {
|
|
263
|
+
const unit = unitOf(w);
|
|
264
|
+
const out: BlameSpan[] = [];
|
|
265
|
+
for (const s of w?.spans ?? []) {
|
|
266
|
+
if (!isNum(s.from) || !isNum(s.to)) continue;
|
|
267
|
+
const from = convert(text, s.from, unit);
|
|
268
|
+
const to = convert(text, s.to, unit);
|
|
269
|
+
if (to <= from) continue;
|
|
270
|
+
const via = VIAS.includes(s.via as BlameVia) ? (s.via as BlameVia) : null;
|
|
271
|
+
const rawReason = str(s.reason).trim();
|
|
272
|
+
const actor = normalizeActor(s);
|
|
273
|
+
// Unknown when the server says so (via or a reason), or when there is no
|
|
274
|
+
// one to credit. Never an author invented from a partial row.
|
|
275
|
+
const unknown = via === 'unknown' || !!rawReason || !(actor.label || actor.sub);
|
|
276
|
+
const reason = reasonOf(rawReason);
|
|
277
|
+
const said =
|
|
278
|
+
str(s.detail).trim() ||
|
|
279
|
+
(reason ? BLAME_REASON_TEXT[reason] : rawReason) ||
|
|
280
|
+
'author not recorded';
|
|
281
|
+
const acceptedRaw = s.accepted_by ?? s.acceptedBy;
|
|
282
|
+
const restored = s.restored_from ?? s.restoredFrom;
|
|
283
|
+
out.push({
|
|
284
|
+
id: `blame:${from}`,
|
|
285
|
+
from,
|
|
286
|
+
to,
|
|
287
|
+
author: unknown ? null : actor,
|
|
288
|
+
unknownReason: unknown ? said : null,
|
|
289
|
+
reason: unknown ? reason : null,
|
|
290
|
+
version: isNum(s.version) ? s.version : null,
|
|
291
|
+
at: s.at ?? null,
|
|
292
|
+
via: unknown ? 'unknown' : via,
|
|
293
|
+
acceptedBy: !unknown && acceptedRaw ? normalizeActor(acceptedRaw) : null,
|
|
294
|
+
restoredFrom: isNum(restored) ? restored : null,
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
return out.sort((a, b) => a.from - b.from);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** A sentence when the field's authorship is not recorded at all (history
|
|
301
|
+
* off on the type: `recorded: false`, `spans: null`), else null. */
|
|
302
|
+
export function blameNotice(w: BlameResponseWire | null | undefined): string | null {
|
|
303
|
+
if (!w || (w.recorded !== false && w.spans !== null)) return null;
|
|
304
|
+
const why = str(w.detail).trim();
|
|
305
|
+
return `Who wrote each passage is not recorded${why ? `: ${why}` : '.'}`;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/* -------------------------------------------------------------------------- */
|
|
309
|
+
/* Suggestions */
|
|
310
|
+
/* -------------------------------------------------------------------------- */
|
|
311
|
+
|
|
312
|
+
const STATUSES: SuggestionStatus[] = ['open', 'accepted', 'rejected', 'withdrawn', 'superseded'];
|
|
313
|
+
|
|
314
|
+
export function normalizeSuggestions(
|
|
315
|
+
w: SuggestionPageWire | SuggestionWire[] | null | undefined,
|
|
316
|
+
text: string,
|
|
317
|
+
): Suggestion[] {
|
|
318
|
+
const unit = Array.isArray(w) ? 'code_point' : unitOf(w);
|
|
319
|
+
return results(w).map((s) => {
|
|
320
|
+
const { anchor, state } = placement(s);
|
|
321
|
+
const r = resolveAnchor(text, anchor, state, unit);
|
|
322
|
+
const status = STATUSES.includes(s.status as SuggestionStatus) ? (s.status as SuggestionStatus) : 'open';
|
|
323
|
+
return {
|
|
324
|
+
id: String(s.id),
|
|
325
|
+
setId: (s.set_id ?? s.setId ?? null) as string | null,
|
|
326
|
+
from: r.from ?? 0,
|
|
327
|
+
to: r.to ?? 0,
|
|
328
|
+
quote: r.quote,
|
|
329
|
+
replacement: str(s.replacement),
|
|
330
|
+
status,
|
|
331
|
+
anchorState: r.state,
|
|
332
|
+
author: normalizeActor(s.author ?? s),
|
|
333
|
+
createdAt: s.created_at ?? s.createdAt ?? null,
|
|
334
|
+
};
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/* -------------------------------------------------------------------------- */
|
|
339
|
+
/* Roster */
|
|
340
|
+
/* -------------------------------------------------------------------------- */
|
|
341
|
+
|
|
342
|
+
/** Colleagues by `sub`, deduped (central's roster pages can repeat a member:
|
|
343
|
+
* startsim-whwxd.7), blank subs dropped, sorted by label. */
|
|
344
|
+
export function normalizeRoster(pages: Array<RosterPageWire | RosterEntryWire[] | null | undefined>): RosterMember[] {
|
|
345
|
+
const bySub = new Map<string, RosterMember>();
|
|
346
|
+
for (const page of pages) {
|
|
347
|
+
for (const r of results(page)) {
|
|
348
|
+
const sub = str(r?.sub).trim();
|
|
349
|
+
if (!sub || bySub.has(sub)) continue;
|
|
350
|
+
bySub.set(sub, { sub, label: str(r?.label).trim() });
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
return [...bySub.values()].sort((a, b) => (a.label || a.sub).localeCompare(b.label || b.sub));
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/* -------------------------------------------------------------------------- */
|
|
357
|
+
/* Comments */
|
|
358
|
+
/* -------------------------------------------------------------------------- */
|
|
359
|
+
|
|
360
|
+
export function normalizeThreads(
|
|
361
|
+
w: CommentPageWire | CommentThreadWire[] | null | undefined,
|
|
362
|
+
text: string,
|
|
363
|
+
): CommentThread[] {
|
|
364
|
+
const unit = Array.isArray(w) ? 'code_point' : unitOf(w);
|
|
365
|
+
return results(w).map((t) => {
|
|
366
|
+
const { anchor, state } = placement(t);
|
|
367
|
+
const r = resolveAnchor(text, anchor, state, unit);
|
|
368
|
+
const res = t.resolution;
|
|
369
|
+
return {
|
|
370
|
+
id: String(t.thread_id ?? t.threadId ?? t.id ?? ''),
|
|
371
|
+
field: typeof t.field === 'string' && t.field ? t.field : null,
|
|
372
|
+
resolution:
|
|
373
|
+
res && typeof res === 'object' && res.action
|
|
374
|
+
? { action: String(res.action), by: normalizeActor(res.by), at: res.at ?? null }
|
|
375
|
+
: null,
|
|
376
|
+
from: r.from,
|
|
377
|
+
to: r.to,
|
|
378
|
+
quote: r.quote,
|
|
379
|
+
anchorState: r.state,
|
|
380
|
+
resolved: t.status === 'resolved' || Boolean(t.resolved),
|
|
381
|
+
redacted: Boolean(t.redacted),
|
|
382
|
+
comments: (t.comments ?? []).map((c) => ({
|
|
383
|
+
id: String(c.id),
|
|
384
|
+
body: c.deleted ? null : (c.body ?? null),
|
|
385
|
+
deleted: Boolean(c.deleted),
|
|
386
|
+
author: normalizeActor(c.author ?? c),
|
|
387
|
+
createdAt: c.created_at ?? c.createdAt ?? null,
|
|
388
|
+
mentions: Array.isArray(c.mentions) ? c.mentions.filter((m): m is string => typeof m === 'string' && !!m) : [],
|
|
389
|
+
editedAt: c.edited_at ?? c.editedAt ?? null,
|
|
390
|
+
})),
|
|
391
|
+
};
|
|
392
|
+
});
|
|
393
|
+
}
|