@hachej/boring-feedback 0.0.0-stage → 0.2.4
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/INVARIANTS.md +35 -0
- package/LICENSE +21 -0
- package/README.md +20 -2
- package/dist/agent/capability.d.ts +52 -0
- package/dist/agent/capability.js +230 -0
- package/dist/agent/index.d.ts +4 -0
- package/dist/agent/index.js +5 -0
- package/dist/agent/preview.d.ts +10 -0
- package/dist/agent/preview.js +27 -0
- package/dist/format/index.d.ts +7 -0
- package/dist/format/index.js +6 -0
- package/dist/format/json.d.ts +30 -0
- package/dist/format/json.js +193 -0
- package/dist/format/preview.d.ts +28 -0
- package/dist/format/preview.js +45 -0
- package/dist/format/readable.d.ts +10 -0
- package/dist/format/readable.js +19 -0
- package/dist/format/report.d.ts +187 -0
- package/dist/format/report.js +669 -0
- package/dist/format/requests.d.ts +53 -0
- package/dist/format/requests.js +69 -0
- package/dist/page/app-element.d.ts +79 -0
- package/dist/page/app-element.js +451 -0
- package/dist/page/canaries.d.ts +46 -0
- package/dist/page/canaries.js +142 -0
- package/dist/page/index.d.ts +6 -0
- package/dist/page/index.js +8 -0
- package/dist/page/picker/index.d.ts +4 -0
- package/dist/page/picker/index.js +4 -0
- package/dist/page/picker/label.d.ts +16 -0
- package/dist/page/picker/label.js +58 -0
- package/dist/page/picker/overlay.d.ts +42 -0
- package/dist/page/picker/overlay.js +126 -0
- package/dist/page/picker/picker.d.ts +72 -0
- package/dist/page/picker/picker.js +386 -0
- package/dist/page/privacy/digest.d.ts +6 -0
- package/dist/page/privacy/digest.js +24 -0
- package/dist/page/privacy/index.d.ts +6 -0
- package/dist/page/privacy/index.js +5 -0
- package/dist/page/privacy/name.d.ts +8 -0
- package/dist/page/privacy/name.js +106 -0
- package/dist/page/privacy/policy.d.ts +65 -0
- package/dist/page/privacy/policy.js +125 -0
- package/dist/page/privacy/route.d.ts +6 -0
- package/dist/page/privacy/route.js +20 -0
- package/dist/page/privacy/serialize.d.ts +42 -0
- package/dist/page/privacy/serialize.js +90 -0
- package/dist/page/voice/align.d.ts +36 -0
- package/dist/page/voice/align.js +122 -0
- package/dist/page/voice/capture.d.ts +43 -0
- package/dist/page/voice/capture.js +186 -0
- package/dist/page/voice/index.d.ts +3 -0
- package/dist/page/voice/index.js +3 -0
- package/dist/preview/agent.d.ts +34 -0
- package/dist/preview/agent.js +91 -0
- package/dist/preview/index.d.ts +5 -0
- package/dist/preview/index.js +6 -0
- package/dist/preview/page.d.ts +50 -0
- package/dist/preview/page.js +293 -0
- package/dist/preview/session.d.ts +63 -0
- package/dist/preview/session.js +110 -0
- package/dist/source/index.d.ts +24 -0
- package/dist/source/index.js +59 -0
- package/dist/source/jsx-dev-runtime.d.ts +5 -0
- package/dist/source/jsx-dev-runtime.js +29 -0
- package/dist/store/index.d.ts +5 -0
- package/dist/store/index.js +4 -0
- package/dist/store/mentions.d.ts +17 -0
- package/dist/store/mentions.js +27 -0
- package/dist/store/store.d.ts +121 -0
- package/dist/store/store.js +353 -0
- package/dist/tickets/index.d.ts +5 -0
- package/dist/tickets/index.js +5 -0
- package/dist/tickets/sinks.d.ts +27 -0
- package/dist/tickets/sinks.js +84 -0
- package/dist/tickets/ticket.d.ts +93 -0
- package/dist/tickets/ticket.js +148 -0
- package/dist/ui/annotate.d.ts +103 -0
- package/dist/ui/annotate.js +154 -0
- package/dist/ui/index.d.ts +7 -0
- package/dist/ui/index.js +8 -0
- package/dist/ui/report.d.ts +51 -0
- package/dist/ui/report.js +58 -0
- package/dist/ui/save.d.ts +45 -0
- package/dist/ui/save.js +65 -0
- package/dist/ui/session.d.ts +186 -0
- package/dist/ui/session.js +762 -0
- package/dist/ui/show.d.ts +43 -0
- package/dist/ui/show.js +72 -0
- package/dist/ui/state.d.ts +11 -0
- package/dist/ui/state.js +21 -0
- package/package.json +98 -4
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type { ResourceAccess, ResourcePublisher, ResourceReader } from '@hachej/boring-files';
|
|
2
|
+
/** One repository of a project; `role: 'app'` marks the application's own repository. */
|
|
3
|
+
export interface ProjectRepo {
|
|
4
|
+
readonly repo: string;
|
|
5
|
+
readonly role?: string;
|
|
6
|
+
}
|
|
7
|
+
/** What the host knows about the project the assistant works for. */
|
|
8
|
+
export interface ProjectInfo {
|
|
9
|
+
readonly name: string;
|
|
10
|
+
readonly repos: readonly ProjectRepo[];
|
|
11
|
+
}
|
|
12
|
+
/** What a sink publishes: the ticket's title, Markdown body (front matter removed), labels and id (the file name without `.md`). */
|
|
13
|
+
export interface Ticket {
|
|
14
|
+
readonly id: string;
|
|
15
|
+
readonly title: string;
|
|
16
|
+
readonly body: string;
|
|
17
|
+
readonly labels: readonly string[];
|
|
18
|
+
}
|
|
19
|
+
export interface TicketSinkContext {
|
|
20
|
+
readonly project: ProjectInfo;
|
|
21
|
+
readonly signal?: AbortSignal;
|
|
22
|
+
}
|
|
23
|
+
export type TicketSinkResult = {
|
|
24
|
+
readonly url: string;
|
|
25
|
+
} | {
|
|
26
|
+
readonly refused: string;
|
|
27
|
+
};
|
|
28
|
+
/** A platform a published ticket is mirrored to: `github`, `file`, later `linear`, `jira`... */
|
|
29
|
+
export interface TicketSink {
|
|
30
|
+
readonly name: string;
|
|
31
|
+
readonly accepts: (project: ProjectInfo) => boolean;
|
|
32
|
+
readonly publish: (ticket: Ticket, context: TicketSinkContext) => Promise<TicketSinkResult>;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* What the front matter's `ticket:` field records. `publishing` is the claim written before the sink is called: if it stays, the sink's
|
|
36
|
+
* outcome was lost and is unknown (check the platform); it is never published again.
|
|
37
|
+
*/
|
|
38
|
+
export type TicketOutcome = {
|
|
39
|
+
readonly sink: string;
|
|
40
|
+
readonly url: string;
|
|
41
|
+
} | {
|
|
42
|
+
readonly sink?: string;
|
|
43
|
+
readonly refused: string;
|
|
44
|
+
} | {
|
|
45
|
+
readonly sink: string;
|
|
46
|
+
readonly state: 'publishing';
|
|
47
|
+
};
|
|
48
|
+
export interface ParsedTicket {
|
|
49
|
+
readonly ticket: Ticket;
|
|
50
|
+
/** Every front matter field, values parsed as JSON when they are JSON. */
|
|
51
|
+
readonly fields: Readonly<Record<string, unknown>>;
|
|
52
|
+
readonly outcome?: TicketOutcome;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Read a ticket's Markdown: optional `---` front matter of `key: value` lines (values are JSON, which is also YAML flow, or plain text),
|
|
56
|
+
* then the body. The title is the `title` field, else the first `# ` heading, else the id; labels are the `labels` field's strings.
|
|
57
|
+
*/
|
|
58
|
+
export declare function parseTicket(id: string, markdown: string): ParsedTicket;
|
|
59
|
+
/** The same Markdown with its front matter's `ticket:` field set to `outcome` (added, or a front matter created). Nothing else changes. */
|
|
60
|
+
export declare function withTicketOutcome(markdown: string, outcome: TicketOutcome): string;
|
|
61
|
+
export interface TicketSinkOptions {
|
|
62
|
+
/** The workspace provider (or its host wrapper): reads the ticket (latest revision) and replaces it conditionally. */
|
|
63
|
+
readonly files: ResourceReader & {
|
|
64
|
+
readonly publication: ResourcePublisher;
|
|
65
|
+
readonly providerId: string;
|
|
66
|
+
};
|
|
67
|
+
readonly project: ProjectInfo;
|
|
68
|
+
/** In order of preference: the first whose `accepts(project)` is true publishes. */
|
|
69
|
+
readonly sinks: readonly TicketSink[];
|
|
70
|
+
/** Folder of the tickets, relative to the workspace. Defaults to `tickets/`. */
|
|
71
|
+
readonly prefix?: string;
|
|
72
|
+
}
|
|
73
|
+
/** What `mirror` did: the outcome now recorded in the ticket, or why nothing was published. */
|
|
74
|
+
export type TicketMirrorResult = {
|
|
75
|
+
readonly kind: 'recorded';
|
|
76
|
+
readonly outcome: TicketOutcome;
|
|
77
|
+
} | {
|
|
78
|
+
readonly kind: 'skipped';
|
|
79
|
+
readonly reason: string;
|
|
80
|
+
};
|
|
81
|
+
export interface TicketSinks {
|
|
82
|
+
/** Whether `path` (workspace-relative) names a ticket. */
|
|
83
|
+
readonly isTicket: (path: string) => boolean;
|
|
84
|
+
/**
|
|
85
|
+
* Mirror the ticket at `path`, which a write just created: claim it, publish it to the first accepting sink and record the outcome.
|
|
86
|
+
* `created` is the revision the creating write left, when the host knows it: a `ticket:` field in that revision was written by the
|
|
87
|
+
* author, not by a sink, so it is replaced rather than trusted. Never throws.
|
|
88
|
+
*/
|
|
89
|
+
readonly mirror: (path: string, access: ResourceAccess, created?: string) => Promise<TicketMirrorResult>;
|
|
90
|
+
}
|
|
91
|
+
/** The ticket sinks over the workspace: `mirror` publishes each new ticket once and writes the outcome back into its front matter. */
|
|
92
|
+
export declare function createTicketSinks(options: TicketSinkOptions): TicketSinks;
|
|
93
|
+
//# sourceMappingURL=ticket.d.ts.map
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
const FRONT = /^---\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/;
|
|
2
|
+
const FIELD = /^([A-Za-z_][A-Za-z0-9_-]*):[ \t]*(.*)$/;
|
|
3
|
+
const ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
|
|
4
|
+
const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
5
|
+
function valueOf(raw) {
|
|
6
|
+
const text = raw.trim();
|
|
7
|
+
if (!text)
|
|
8
|
+
return '';
|
|
9
|
+
try {
|
|
10
|
+
return JSON.parse(text);
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return text.replace(/^(["'])(.*)\1$/, '$2');
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
function outcomeOf(value) {
|
|
17
|
+
if (!isObject(value))
|
|
18
|
+
return undefined;
|
|
19
|
+
const sink = typeof value['sink'] === 'string' ? value['sink'] : undefined;
|
|
20
|
+
if (sink && typeof value['url'] === 'string')
|
|
21
|
+
return { sink, url: value['url'] };
|
|
22
|
+
if (typeof value['refused'] === 'string')
|
|
23
|
+
return { ...(sink ? { sink } : {}), refused: value['refused'] };
|
|
24
|
+
if (sink && value['state'] === 'publishing')
|
|
25
|
+
return { sink, state: 'publishing' };
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Read a ticket's Markdown: optional `---` front matter of `key: value` lines (values are JSON, which is also YAML flow, or plain text),
|
|
30
|
+
* then the body. The title is the `title` field, else the first `# ` heading, else the id; labels are the `labels` field's strings.
|
|
31
|
+
*/
|
|
32
|
+
export function parseTicket(id, markdown) {
|
|
33
|
+
const match = FRONT.exec(markdown);
|
|
34
|
+
const fields = {};
|
|
35
|
+
if (match)
|
|
36
|
+
for (const line of match[1].split(/\r?\n/)) {
|
|
37
|
+
const field = FIELD.exec(line);
|
|
38
|
+
if (field)
|
|
39
|
+
fields[field[1]] = valueOf(field[2]);
|
|
40
|
+
}
|
|
41
|
+
const body = (match ? markdown.slice(match[0].length) : markdown).trim();
|
|
42
|
+
const heading = /^#[ \t]+(.+)$/m.exec(body)?.[1]?.trim();
|
|
43
|
+
const title = typeof fields['title'] === 'string' && fields['title'].trim() ? fields['title'].trim() : heading || id;
|
|
44
|
+
const labels = Array.isArray(fields['labels']) ? [...new Set(fields['labels'].filter((label) => typeof label === 'string' && label.trim() !== '').map(label => label.trim()))] : [];
|
|
45
|
+
const outcome = outcomeOf(fields['ticket']);
|
|
46
|
+
return { ticket: { id, title, body, labels }, fields, ...(outcome ? { outcome } : {}) };
|
|
47
|
+
}
|
|
48
|
+
/** The same Markdown with its front matter's `ticket:` field set to `outcome` (added, or a front matter created). Nothing else changes. */
|
|
49
|
+
export function withTicketOutcome(markdown, outcome) {
|
|
50
|
+
const line = `ticket: ${JSON.stringify(outcome)}`;
|
|
51
|
+
const match = FRONT.exec(markdown);
|
|
52
|
+
if (!match)
|
|
53
|
+
return `---\n${line}\n---\n${markdown}`;
|
|
54
|
+
const lines = match[1].split(/\r?\n/);
|
|
55
|
+
const at = lines.findIndex(item => /^ticket:/.test(item));
|
|
56
|
+
if (at >= 0)
|
|
57
|
+
lines[at] = line;
|
|
58
|
+
else
|
|
59
|
+
lines.push(line);
|
|
60
|
+
return `---\n${lines.join('\n')}\n---\n${markdown.slice(match[0].length)}`;
|
|
61
|
+
}
|
|
62
|
+
const decode = (bytes) => { try {
|
|
63
|
+
return new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes);
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return undefined;
|
|
67
|
+
} };
|
|
68
|
+
const escapeRegExp = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
69
|
+
let operations = 0;
|
|
70
|
+
/** The ticket sinks over the workspace: `mirror` publishes each new ticket once and writes the outcome back into its front matter. */
|
|
71
|
+
export function createTicketSinks(options) {
|
|
72
|
+
const { files, project, sinks } = options;
|
|
73
|
+
const prefix = options.prefix ?? 'tickets/';
|
|
74
|
+
if (prefix && (!prefix.endsWith('/') || prefix.startsWith('/')))
|
|
75
|
+
throw new TypeError('The ticket folder must be relative and end with a slash');
|
|
76
|
+
const pattern = new RegExp(`^${escapeRegExp(prefix)}([A-Za-z0-9][A-Za-z0-9_-]{0,63})\\.md$`);
|
|
77
|
+
const idOf = (path) => { const id = pattern.exec(path)?.[1]; return id && ID.test(id) ? id : undefined; };
|
|
78
|
+
async function latest(path, access) {
|
|
79
|
+
const read = await files.read({ target: { resource: { providerId: files.providerId, path }, view: { kind: 'published' } }, revision: { kind: 'latest' } }, access);
|
|
80
|
+
if (read.kind !== 'available')
|
|
81
|
+
return undefined;
|
|
82
|
+
const text = decode(read.snapshot.bytes);
|
|
83
|
+
return text === undefined ? undefined : { ref: read.snapshot.ref, text, mediaType: read.snapshot.mediaType };
|
|
84
|
+
}
|
|
85
|
+
/** Conditional replace of the exact revision read, with `outcome` in the front matter. Returns whether it committed. */
|
|
86
|
+
async function record(current, outcome, access) {
|
|
87
|
+
const operationId = `boring.ticket.${current.ref.resource.path}.${current.ref.revision}.${Date.now().toString(36)}.${++operations}`;
|
|
88
|
+
const result = await files.publication.publish({ operationId, atomicity: 'all-or-nothing', changes: [
|
|
89
|
+
{ kind: 'replace', target: current.ref, bytes: new TextEncoder().encode(withTicketOutcome(current.text, outcome)), mediaType: current.mediaType },
|
|
90
|
+
] }, access);
|
|
91
|
+
return result.kind === 'committed';
|
|
92
|
+
}
|
|
93
|
+
async function mirror(path, access, created) {
|
|
94
|
+
const id = idOf(path);
|
|
95
|
+
if (!id)
|
|
96
|
+
return { kind: 'skipped', reason: 'Not a ticket path' };
|
|
97
|
+
const current = await latest(path, access);
|
|
98
|
+
if (!current)
|
|
99
|
+
return { kind: 'skipped', reason: 'The ticket could not be read' };
|
|
100
|
+
const parsed = parseTicket(id, current.text);
|
|
101
|
+
// Already claimed or published: never twice. A `ticket:` field in the created revision itself was written by the author.
|
|
102
|
+
if (parsed.outcome && current.ref.revision !== created)
|
|
103
|
+
return { kind: 'skipped', reason: 'The ticket already records a sink outcome' };
|
|
104
|
+
const sink = sinks.find(candidate => { try {
|
|
105
|
+
return candidate.accepts(project);
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return false;
|
|
109
|
+
} });
|
|
110
|
+
if (!sink) {
|
|
111
|
+
const outcome = { refused: `No ticket sink accepts the project "${project.name}"` };
|
|
112
|
+
return await record(current, outcome, access) ? { kind: 'recorded', outcome } : { kind: 'skipped', reason: 'The ticket changed before the refusal was recorded' };
|
|
113
|
+
}
|
|
114
|
+
// The claim: a conditional replace of the revision just read. Losing it (a person edited the ticket, another call claimed it)
|
|
115
|
+
// publishes nothing.
|
|
116
|
+
if (!await record(current, { sink: sink.name, state: 'publishing' }, access))
|
|
117
|
+
return { kind: 'skipped', reason: 'The ticket changed before it was claimed' };
|
|
118
|
+
let result;
|
|
119
|
+
try {
|
|
120
|
+
result = await sink.publish(parsed.ticket, { project, ...(access.signal ? { signal: access.signal } : {}) });
|
|
121
|
+
}
|
|
122
|
+
catch (error) {
|
|
123
|
+
result = { refused: `The ${sink.name} sink did not complete (${error instanceof Error ? error.message : 'unknown error'}); check ${sink.name} before filing again` };
|
|
124
|
+
}
|
|
125
|
+
const outcome = 'url' in result ? { sink: sink.name, url: result.url } : { sink: sink.name, refused: result.refused };
|
|
126
|
+
// The outcome replaces the claim. A person editing between the two writes only costs a re-read; three tries, then the claim stays.
|
|
127
|
+
for (let attempt = 1; attempt <= 3; attempt++) {
|
|
128
|
+
const claimed = await latest(path, access);
|
|
129
|
+
const state = claimed ? parseTicket(id, claimed.text).outcome : undefined;
|
|
130
|
+
if (!claimed || !state || !('state' in state))
|
|
131
|
+
break;
|
|
132
|
+
if (await record(claimed, outcome, access))
|
|
133
|
+
return { kind: 'recorded', outcome };
|
|
134
|
+
}
|
|
135
|
+
return { kind: 'recorded', outcome: { sink: sink.name, state: 'publishing' } };
|
|
136
|
+
}
|
|
137
|
+
return Object.freeze({
|
|
138
|
+
isTicket: (path) => idOf(path) !== undefined,
|
|
139
|
+
mirror: async (path, access, created) => {
|
|
140
|
+
try {
|
|
141
|
+
return await mirror(path, { ...access }, created);
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
return { kind: 'skipped', reason: 'The ticket stays as written; its front matter shows no outcome' };
|
|
145
|
+
}
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { type FeedbackAnchor, type FeedbackDraft, type HostObserved } from '../format/index.js';
|
|
2
|
+
import { type AppDomSnapshot, type PrivacyPolicy, type RouteLocation } from '../page/index.js';
|
|
3
|
+
import type { SaveEndpoint, SaveResult } from './save.js';
|
|
4
|
+
import { type ExternalState } from './state.js';
|
|
5
|
+
/** What the host tells annotation about its page. `app` and `build` are the host's; the route comes from the policy. */
|
|
6
|
+
export interface AnnotateHost {
|
|
7
|
+
readonly app: string;
|
|
8
|
+
readonly build?: string;
|
|
9
|
+
/** The application root: the only pickable and serialized part of the page. */
|
|
10
|
+
readonly root: Element;
|
|
11
|
+
readonly policy: PrivacyPolicy;
|
|
12
|
+
/** The current location; defaults to the root's window location. Only its path reaches `routeFor`. */
|
|
13
|
+
readonly location?: () => RouteLocation;
|
|
14
|
+
}
|
|
15
|
+
export interface AnnotationCapture {
|
|
16
|
+
readonly observed: HostObserved;
|
|
17
|
+
readonly anchors: readonly FeedbackAnchor[];
|
|
18
|
+
/** Pinned elements that could not be anchored, by pin position, with the reason. */
|
|
19
|
+
readonly refused: readonly {
|
|
20
|
+
readonly index: number;
|
|
21
|
+
readonly reason: string;
|
|
22
|
+
}[];
|
|
23
|
+
/** The one page snapshot every anchor and the digest came from. */
|
|
24
|
+
readonly snapshot: AppDomSnapshot;
|
|
25
|
+
}
|
|
26
|
+
/** What was observed now: one masked page snapshot and the `host` observation (subject, route template, digest and policy). */
|
|
27
|
+
export declare function observePage(host: AnnotateHost): Promise<{
|
|
28
|
+
readonly observed: HostObserved;
|
|
29
|
+
readonly snapshot: AppDomSnapshot;
|
|
30
|
+
}>;
|
|
31
|
+
/** Anchors every pinned element against one masked page snapshot and records what was observed: subject, digest and policy. */
|
|
32
|
+
export declare function captureAnnotation(host: AnnotateHost, elements: readonly Element[]): Promise<AnnotationCapture>;
|
|
33
|
+
/** A general note's capture: the page observed, NO element (a question that is not about a zone). Same as `captureAnnotation(host, [])`. */
|
|
34
|
+
export declare const captureGeneral: (host: AnnotateHost) => Promise<AnnotationCapture>;
|
|
35
|
+
export type CopyState = {
|
|
36
|
+
readonly kind: 'idle';
|
|
37
|
+
}
|
|
38
|
+
/** On the clipboard. The text is kept so the sheet can show what was copied. */
|
|
39
|
+
| {
|
|
40
|
+
readonly kind: 'copied';
|
|
41
|
+
readonly text: string;
|
|
42
|
+
}
|
|
43
|
+
/** The clipboard refused: the sheet shows the text for a manual copy. */
|
|
44
|
+
| {
|
|
45
|
+
readonly kind: 'manual';
|
|
46
|
+
readonly text: string;
|
|
47
|
+
} | {
|
|
48
|
+
readonly kind: 'refused';
|
|
49
|
+
readonly reason: string;
|
|
50
|
+
};
|
|
51
|
+
export type SaveState = {
|
|
52
|
+
readonly kind: 'idle';
|
|
53
|
+
} | {
|
|
54
|
+
readonly kind: 'saving';
|
|
55
|
+
} | SaveResult;
|
|
56
|
+
export interface AnnotationSnapshot {
|
|
57
|
+
readonly said: string;
|
|
58
|
+
readonly anchors: readonly FeedbackAnchor[];
|
|
59
|
+
readonly refused: readonly {
|
|
60
|
+
readonly index: number;
|
|
61
|
+
readonly reason: string;
|
|
62
|
+
}[];
|
|
63
|
+
readonly observed: HostObserved;
|
|
64
|
+
/** Every anchor's fallback, for the sheet ("the «Save» button"). */
|
|
65
|
+
readonly targets: readonly string[];
|
|
66
|
+
readonly copy: CopyState;
|
|
67
|
+
readonly save: SaveState;
|
|
68
|
+
/** False without a host save endpoint: the sheet shows Copy only. */
|
|
69
|
+
readonly canSave: boolean;
|
|
70
|
+
/** The operation id the next Save sends; it stays the same across retries of this draft. */
|
|
71
|
+
readonly operationId: string;
|
|
72
|
+
/** True while the note is about pinned element(s). False after `clearSelection`, or when it was made without any: a general note. */
|
|
73
|
+
readonly selected: boolean;
|
|
74
|
+
}
|
|
75
|
+
export interface Annotation extends ExternalState<AnnotationSnapshot> {
|
|
76
|
+
readonly setSaid: (said: string) => void;
|
|
77
|
+
/**
|
|
78
|
+
* Cancels the selection: the note stops being about the pinned element(s) and becomes a general note, with NO anchor (the
|
|
79
|
+
* `feedback@1` report allows it: `anchors: []` and the text in `## Said`). What was typed is kept. Returns false, and changes
|
|
80
|
+
* nothing, when there is no selection or the draft is being saved or already saved.
|
|
81
|
+
*/
|
|
82
|
+
readonly clearSelection: () => boolean;
|
|
83
|
+
readonly draft: () => FeedbackDraft;
|
|
84
|
+
/** Copy report: the never-stored report rendered through `./format`, on the clipboard. Needs no store, route or agent. */
|
|
85
|
+
readonly copy: () => Promise<CopyState>;
|
|
86
|
+
/** Save through the host endpoint, when there is one. */
|
|
87
|
+
readonly save: () => Promise<SaveState>;
|
|
88
|
+
}
|
|
89
|
+
export interface AnnotationOptions {
|
|
90
|
+
readonly capture: AnnotationCapture;
|
|
91
|
+
readonly save?: SaveEndpoint;
|
|
92
|
+
readonly said?: string;
|
|
93
|
+
readonly now?: () => Date;
|
|
94
|
+
/** Defaults to `copyToClipboard` from `@hachej/boring-files/platform`. */
|
|
95
|
+
readonly copyText?: (text: string) => Promise<boolean>;
|
|
96
|
+
}
|
|
97
|
+
/** A `feedback@1` id for a report that is only copied, never stored. */
|
|
98
|
+
export declare function draftId(): string;
|
|
99
|
+
/** A fresh Save operation id for one draft. */
|
|
100
|
+
export declare const operationId: () => string;
|
|
101
|
+
/** The headless annotate sheet. */
|
|
102
|
+
export declare function createAnnotation({ capture, save, said, now, copyText }: AnnotationOptions): Annotation;
|
|
103
|
+
//# sourceMappingURL=annotate.d.ts.map
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// Annotate state (FEEDBACK.md, "Release 1: application pages"): the pinned elements become `app.element@1` anchors and a `host`
|
|
2
|
+
// observation from ONE masked page snapshot, the person types what they want to say, and the draft is copied (no store, no agent:
|
|
3
|
+
// FEEDBACK-2, FEEDBACK-5) or saved through the host's endpoint with one operation id per draft across retries.
|
|
4
|
+
import { copyToClipboard, randomUUID } from '@hachej/boring-files/platform';
|
|
5
|
+
import { draftReport, feedbackText, FEEDBACK_LIMITS } from '../format/index.js';
|
|
6
|
+
import { anchorOf, pageDigest, policyRecord, routeFor, serializePage } from '../page/index.js';
|
|
7
|
+
import { createState } from './state.js';
|
|
8
|
+
/** What was observed now: one masked page snapshot and the `host` observation (subject, route template, digest and policy). */
|
|
9
|
+
export async function observePage(host) {
|
|
10
|
+
const { root, policy } = host;
|
|
11
|
+
const snapshot = serializePage(root, policy);
|
|
12
|
+
const digest = await pageDigest(snapshot);
|
|
13
|
+
const location = host.location?.() ?? root.ownerDocument.defaultView?.location ?? { pathname: '/' };
|
|
14
|
+
const observed = {
|
|
15
|
+
kind: 'host',
|
|
16
|
+
subject: { type: 'app-page', app: host.app, route: routeFor({ pathname: location.pathname }, policy), ...(host.build !== undefined ? { build: host.build } : {}) },
|
|
17
|
+
snapshot: snapshot.format,
|
|
18
|
+
digest: `sha256:${digest}`,
|
|
19
|
+
policy: policyRecord(policy),
|
|
20
|
+
};
|
|
21
|
+
return { observed, snapshot };
|
|
22
|
+
}
|
|
23
|
+
/** Anchors every pinned element against one masked page snapshot and records what was observed: subject, digest and policy. */
|
|
24
|
+
export async function captureAnnotation(host, elements) {
|
|
25
|
+
const { root, policy } = host;
|
|
26
|
+
const { observed, snapshot } = await observePage(host);
|
|
27
|
+
const anchors = [];
|
|
28
|
+
const refused = [];
|
|
29
|
+
elements.slice(0, FEEDBACK_LIMITS.anchors).forEach((element, index) => {
|
|
30
|
+
const captured = anchorOf(element, policy, { root, page: snapshot });
|
|
31
|
+
if (captured.kind === 'captured')
|
|
32
|
+
anchors.push(captured.anchor);
|
|
33
|
+
else
|
|
34
|
+
refused.push({ index, reason: captured.reason });
|
|
35
|
+
});
|
|
36
|
+
if (elements.length > FEEDBACK_LIMITS.anchors)
|
|
37
|
+
refused.push({ index: FEEDBACK_LIMITS.anchors, reason: `at most ${FEEDBACK_LIMITS.anchors} elements per report` });
|
|
38
|
+
return Object.freeze({ observed, anchors: Object.freeze(anchors), refused: Object.freeze(refused), snapshot });
|
|
39
|
+
}
|
|
40
|
+
/** A general note's capture: the page observed, NO element (a question that is not about a zone). Same as `captureAnnotation(host, [])`. */
|
|
41
|
+
export const captureGeneral = (host) => captureAnnotation(host, []);
|
|
42
|
+
const BASE58 = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
|
|
43
|
+
/** A `feedback@1` id for a report that is only copied, never stored. */
|
|
44
|
+
export function draftId() {
|
|
45
|
+
let out = '';
|
|
46
|
+
while (out.length < 16) {
|
|
47
|
+
const hex = randomUUID().replace(/-/g, '');
|
|
48
|
+
const random = hex.slice(0, 12) + hex.slice(13, 16) + hex.slice(17);
|
|
49
|
+
for (let at = 0; at + 2 <= random.length && out.length < 16; at += 2) {
|
|
50
|
+
const byte = Number.parseInt(random.slice(at, at + 2), 16);
|
|
51
|
+
if (byte < 232)
|
|
52
|
+
out += BASE58[byte % 58];
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return `fb_${out}`;
|
|
56
|
+
}
|
|
57
|
+
/** A fresh Save operation id for one draft. */
|
|
58
|
+
export const operationId = () => `draft_${randomUUID()}`;
|
|
59
|
+
/** The headless annotate sheet. */
|
|
60
|
+
export function createAnnotation({ capture, save, said = '', now = () => new Date(), copyText = copyToClipboard }) {
|
|
61
|
+
const state = createState({
|
|
62
|
+
said, anchors: capture.anchors, refused: capture.refused, observed: capture.observed,
|
|
63
|
+
targets: Object.freeze(capture.anchors.map(anchor => String(anchor['fallback'] ?? ''))),
|
|
64
|
+
copy: { kind: 'idle' }, save: { kind: 'idle' }, canSave: save !== undefined, operationId: operationId(),
|
|
65
|
+
selected: capture.anchors.length > 0 || capture.refused.length > 0,
|
|
66
|
+
});
|
|
67
|
+
// Whether the current operation id may have reached the store with some text: then it is kept even when the text changes, and the
|
|
68
|
+
// store answers honestly (the original report for the same draft, a conflict for a changed one). Only definite refusals free it.
|
|
69
|
+
let sent = false;
|
|
70
|
+
const draft = () => {
|
|
71
|
+
const current = state.getSnapshot();
|
|
72
|
+
return { observed: current.observed, anchors: current.anchors, said: current.said };
|
|
73
|
+
};
|
|
74
|
+
const problem = () => {
|
|
75
|
+
const current = state.getSnapshot();
|
|
76
|
+
if (!current.said.trim())
|
|
77
|
+
return 'Write what you want to say first.';
|
|
78
|
+
// A general note (no element pinned) is a note; pinned elements that all failed to anchor are not, or it would silently lose the zone.
|
|
79
|
+
if (!current.anchors.length && current.refused.length)
|
|
80
|
+
return 'No element could be anchored.';
|
|
81
|
+
return undefined;
|
|
82
|
+
};
|
|
83
|
+
return {
|
|
84
|
+
getSnapshot: state.getSnapshot,
|
|
85
|
+
subscribe: state.subscribe,
|
|
86
|
+
draft,
|
|
87
|
+
setSaid: next => {
|
|
88
|
+
const current = state.getSnapshot();
|
|
89
|
+
if (next === current.said)
|
|
90
|
+
return;
|
|
91
|
+
const settled = current.save.kind === 'saved';
|
|
92
|
+
state.set({
|
|
93
|
+
said: next, copy: { kind: 'idle' },
|
|
94
|
+
...(settled ? {} : { save: { kind: 'idle' } }),
|
|
95
|
+
...(!sent && !settled ? { operationId: operationId() } : {}),
|
|
96
|
+
});
|
|
97
|
+
},
|
|
98
|
+
clearSelection: () => {
|
|
99
|
+
const current = state.getSnapshot();
|
|
100
|
+
if (!current.selected || current.save.kind === 'saving' || current.save.kind === 'saved')
|
|
101
|
+
return false;
|
|
102
|
+
state.set({
|
|
103
|
+
anchors: Object.freeze([]), refused: Object.freeze([]), targets: Object.freeze([]), selected: false,
|
|
104
|
+
copy: { kind: 'idle' }, save: { kind: 'idle' },
|
|
105
|
+
// The draft changed: a fresh operation id, unless the current one may already have reached the store.
|
|
106
|
+
...(!sent ? { operationId: operationId() } : {}),
|
|
107
|
+
});
|
|
108
|
+
return true;
|
|
109
|
+
},
|
|
110
|
+
copy: async () => {
|
|
111
|
+
const refused = problem();
|
|
112
|
+
if (refused)
|
|
113
|
+
return state.set({ copy: { kind: 'refused', reason: refused } }).copy;
|
|
114
|
+
let text;
|
|
115
|
+
try {
|
|
116
|
+
text = feedbackText(draftReport(draft(), { id: draftId(), created: now().toISOString() }));
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
return state.set({ copy: { kind: 'refused', reason: error instanceof Error ? error.message : 'The report could not be rendered.' } }).copy;
|
|
120
|
+
}
|
|
121
|
+
let copied = false;
|
|
122
|
+
try {
|
|
123
|
+
copied = await copyText(text);
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
copied = false;
|
|
127
|
+
}
|
|
128
|
+
return state.set({ copy: copied ? { kind: 'copied', text } : { kind: 'manual', text } }).copy;
|
|
129
|
+
},
|
|
130
|
+
save: async () => {
|
|
131
|
+
const current = state.getSnapshot();
|
|
132
|
+
if (!save)
|
|
133
|
+
return state.set({ save: { kind: 'unavailable', reason: 'This page has no place to save feedback; use Copy.' } }).save;
|
|
134
|
+
if (current.save.kind === 'saving' || current.save.kind === 'saved')
|
|
135
|
+
return current.save;
|
|
136
|
+
const refused = problem();
|
|
137
|
+
if (refused)
|
|
138
|
+
return state.set({ save: { kind: 'invalid', reason: refused } }).save;
|
|
139
|
+
state.set({ save: { kind: 'saving' } });
|
|
140
|
+
sent = true;
|
|
141
|
+
let result;
|
|
142
|
+
try {
|
|
143
|
+
result = await save({ operationId: current.operationId, draft: draft() });
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
result = { kind: 'unknown', reason: 'The save did not answer; Save again to reconcile it.' };
|
|
147
|
+
}
|
|
148
|
+
// Definite refusals stored nothing: the next attempt may use a fresh id if the text changes.
|
|
149
|
+
if (result.kind === 'denied' || result.kind === 'unavailable' || result.kind === 'invalid')
|
|
150
|
+
sent = false;
|
|
151
|
+
return state.set({ save: result }).save;
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { createState, type ExternalState, type WritableState } from './state.js';
|
|
2
|
+
export { captureAnnotation, captureGeneral, createAnnotation, observePage, type AnnotateHost, type AnnotationCapture, type Annotation, type AnnotationOptions, type AnnotationSnapshot, type CopyState, type SaveState, } from './annotate.js';
|
|
3
|
+
export { fetchSaveEndpoint, saveResultOf, parseSaveRequest, saveResponseOf, type SaveRequest, type SaveResult, type SaveEndpoint, type StoreCreateResult, } from './save.js';
|
|
4
|
+
export { showAnchor, type ShowOutcome, type ShowCandidate, type ShowOptions } from './show.js';
|
|
5
|
+
export { protectionNotice, ageOf, subjectLabel, listRows, reportView, type FeedbackProtection, type FeedbackRow, type ReportView, type ReportAnchorView, } from './report.js';
|
|
6
|
+
export { createFeedbackSession, isPointing, pinMark, type FeedbackSession, type SessionMode, type SessionStartOptions, type FeedbackSessionOptions, type FeedbackSessionSnapshot, type FeedbackAttachment, type SessionPhase, type SessionNote, type SessionBubble, type SendState, type VoiceState, type Transcribe, type TranscribeResult, } from './session.js';
|
|
7
|
+
//# sourceMappingURL=index.d.ts.map
|
package/dist/ui/index.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// @hachej/boring-feedback/ui (WP6): framework-light annotate state, Copy, draft operation ids for Save, list and report data, and Show.
|
|
2
|
+
// Browser code over `./page` and `./format`; no store, agent or view-layer import. Each state exposes `subscribe`/`getSnapshot`.
|
|
3
|
+
export { createState } from './state.js';
|
|
4
|
+
export { captureAnnotation, captureGeneral, createAnnotation, observePage, } from './annotate.js';
|
|
5
|
+
export { fetchSaveEndpoint, saveResultOf, parseSaveRequest, saveResponseOf, } from './save.js';
|
|
6
|
+
export { showAnchor } from './show.js';
|
|
7
|
+
export { protectionNotice, ageOf, subjectLabel, listRows, reportView, } from './report.js';
|
|
8
|
+
export { createFeedbackSession, isPointing, pinMark, } from './session.js';
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { type FeedbackListItem, type FeedbackReport, type FeedbackStatus } from '../format/index.js';
|
|
2
|
+
export type FeedbackProtection = 'protected' | 'unprotected';
|
|
3
|
+
/** The sentence every surface shows for an unprotected store; nothing for a protected one. */
|
|
4
|
+
export declare function protectionNotice(protection: FeedbackProtection): string | undefined;
|
|
5
|
+
/** "just now", "5 min ago", "3 h ago", "2 days ago" relative to `now`. */
|
|
6
|
+
export declare function ageOf(created: string, now?: Date): string;
|
|
7
|
+
export interface FeedbackRow {
|
|
8
|
+
readonly id: string;
|
|
9
|
+
readonly title: string;
|
|
10
|
+
readonly status: FeedbackStatus;
|
|
11
|
+
/** `northwind-console /settings/:section` for host subjects; the raw key otherwise. */
|
|
12
|
+
readonly where: string;
|
|
13
|
+
readonly author: string;
|
|
14
|
+
readonly age: string;
|
|
15
|
+
}
|
|
16
|
+
/** `host:app-page:<app>:<route>` (parts URI-encoded by `subjectKeyOf`) as `<app> <route>`. */
|
|
17
|
+
export declare function subjectLabel(key: string): string;
|
|
18
|
+
export declare function listRows(items: readonly FeedbackListItem[], now?: Date): readonly FeedbackRow[];
|
|
19
|
+
export interface ReportAnchorView {
|
|
20
|
+
readonly index: number;
|
|
21
|
+
readonly kind: string;
|
|
22
|
+
readonly fallback: string;
|
|
23
|
+
/** Development source location, when the anchor carries one. */
|
|
24
|
+
readonly source?: string;
|
|
25
|
+
/** Whether this page can place it (Show). */
|
|
26
|
+
readonly placeable: boolean;
|
|
27
|
+
}
|
|
28
|
+
export interface ReportView {
|
|
29
|
+
readonly id: string;
|
|
30
|
+
readonly title: string;
|
|
31
|
+
readonly said: string;
|
|
32
|
+
/** The report's notes, each with the fallback of the anchor it is about (none for a general note). */
|
|
33
|
+
readonly notes: readonly {
|
|
34
|
+
readonly number: number;
|
|
35
|
+
readonly text: string;
|
|
36
|
+
readonly anchor?: number;
|
|
37
|
+
readonly fallback?: string;
|
|
38
|
+
readonly voice: boolean;
|
|
39
|
+
}[];
|
|
40
|
+
readonly status: FeedbackStatus;
|
|
41
|
+
readonly author?: string;
|
|
42
|
+
readonly created: string;
|
|
43
|
+
readonly app?: string;
|
|
44
|
+
readonly route?: string;
|
|
45
|
+
readonly build?: string;
|
|
46
|
+
readonly widened: readonly string[];
|
|
47
|
+
readonly anchors: readonly ReportAnchorView[];
|
|
48
|
+
readonly resolutions: FeedbackReport['resolutions'];
|
|
49
|
+
}
|
|
50
|
+
export declare function reportView(report: FeedbackReport, placeable?: (kind: string) => boolean): ReportView;
|
|
51
|
+
//# sourceMappingURL=report.d.ts.map
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// List and report data for `FeedbackList` and `FeedbackReport`: plain values derived from the stored reports and their list items, with the
|
|
2
|
+
// store's protection stated (FEEDBACK-1). The host fetches them through its own authorized routes.
|
|
3
|
+
import { isHostObserved, reportTitle } from '../format/index.js';
|
|
4
|
+
/** The sentence every surface shows for an unprotected store; nothing for a protected one. */
|
|
5
|
+
export function protectionNotice(protection) {
|
|
6
|
+
return protection === 'unprotected' ? 'This feedback is stored where working tools, shell and Git can change it: the store is unprotected.' : undefined;
|
|
7
|
+
}
|
|
8
|
+
/** "just now", "5 min ago", "3 h ago", "2 days ago" relative to `now`. */
|
|
9
|
+
export function ageOf(created, now = new Date()) {
|
|
10
|
+
const seconds = Math.max(0, Math.round((now.getTime() - Date.parse(created)) / 1000));
|
|
11
|
+
if (!Number.isFinite(seconds) || seconds < 60)
|
|
12
|
+
return 'just now';
|
|
13
|
+
if (seconds < 3600)
|
|
14
|
+
return `${Math.floor(seconds / 60)} min ago`;
|
|
15
|
+
if (seconds < 86400)
|
|
16
|
+
return `${Math.floor(seconds / 3600)} h ago`;
|
|
17
|
+
const days = Math.floor(seconds / 86400);
|
|
18
|
+
return `${days} day${days === 1 ? '' : 's'} ago`;
|
|
19
|
+
}
|
|
20
|
+
/** `host:app-page:<app>:<route>` (parts URI-encoded by `subjectKeyOf`) as `<app> <route>`. */
|
|
21
|
+
export function subjectLabel(key) {
|
|
22
|
+
const match = /^host:([^:]*):([^:]*):(.*)$/.exec(key);
|
|
23
|
+
if (!match)
|
|
24
|
+
return key;
|
|
25
|
+
const decode = (part) => { try {
|
|
26
|
+
return decodeURIComponent(part);
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return part;
|
|
30
|
+
} };
|
|
31
|
+
return `${decode(match[2] ?? '')} ${decode(match[3] ?? '')}`.trim();
|
|
32
|
+
}
|
|
33
|
+
export function listRows(items, now = new Date()) {
|
|
34
|
+
return Object.freeze(items.map(item => Object.freeze({
|
|
35
|
+
id: item.id, title: item.title || '(no text)', status: item.status, where: subjectLabel(item.subject), author: item.author, age: ageOf(item.created, now),
|
|
36
|
+
})));
|
|
37
|
+
}
|
|
38
|
+
export function reportView(report, placeable = kind => kind === 'app.element@1') {
|
|
39
|
+
const observed = report.observed;
|
|
40
|
+
const host = isHostObserved(observed) ? observed : undefined;
|
|
41
|
+
const title = reportTitle(report) || '(no text)';
|
|
42
|
+
const notes = (report.notes ?? []).map((note, index) => {
|
|
43
|
+
const fallback = note.anchor !== undefined ? report.anchors[note.anchor]?.fallback : undefined;
|
|
44
|
+
return Object.freeze({ number: index + 1, text: note.text, ...(note.anchor !== undefined ? { anchor: note.anchor } : {}), ...(fallback !== undefined ? { fallback } : {}), voice: note.from === 'voice' });
|
|
45
|
+
});
|
|
46
|
+
return Object.freeze({
|
|
47
|
+
id: report.id, title, said: report.said, notes: Object.freeze(notes), status: report.status, created: report.created,
|
|
48
|
+
...(report.author ? { author: report.author.display || report.author.principalId } : {}),
|
|
49
|
+
...(host ? { app: host.subject.app, route: host.subject.route, ...(host.subject.build !== undefined ? { build: host.subject.build } : {}) } : {}),
|
|
50
|
+
widened: Object.freeze([...(host?.policy.widened ?? [])]),
|
|
51
|
+
anchors: Object.freeze(report.anchors.map((anchor, index) => {
|
|
52
|
+
const signals = anchor['signals'];
|
|
53
|
+
const source = typeof signals === 'object' && signals !== null && !Array.isArray(signals) ? signals['source'] : undefined;
|
|
54
|
+
return Object.freeze({ index, kind: anchor.kind, fallback: anchor.fallback, ...(typeof source === 'string' ? { source } : {}), placeable: placeable(anchor.kind) });
|
|
55
|
+
})),
|
|
56
|
+
resolutions: report.resolutions,
|
|
57
|
+
});
|
|
58
|
+
}
|