import { normalizeHandle } from "#lib/handle.ts"; export type TicketState = "open" | "closed" | "completed"; export interface QueryValue { text: string; quoted: boolean; start: number; end: number; } interface TokenBase { negated: boolean; /** the full token with the minus sign */ start: number; end: number; } export type QueryToken = | (TokenBase & { kind: "word"; text: string }) | (TokenBase & { kind: "phrase"; text: string }) | (TokenBase & { kind: "qualifier"; key: string; /** the end of the key and the colon */ keyEnd: number; values: QueryValue[]; known: boolean; }); export const QUALIFIERS = ["is", "label", "author", "sort"] as const; export type QualifierKey = (typeof QUALIFIERS)[number]; const isSpace = (ch: string | undefined) => ch !== undefined && /\s/.test(ch); /** a phrase without a closing quote goes to the end */ const scanPhrase = (input: string, at: number): { text: string; end: number } => { let i = at + 1; let text = ""; while (i < input.length) { const ch = input[i]; if (ch === "\\") { if (i + 1 < input.length) { text += input[i + 1]; i += 2; } else { text += ch; i += 1; } continue; } if (ch === '"') return { text, end: i + 1 }; text += ch; i += 1; } return { text, end: i }; }; const scanValues = (input: string, at: number): { values: QueryValue[]; end: number } => { const values: QueryValue[] = []; let i = at; for (;;) { if (input[i] === '"') { const phrase = scanPhrase(input, i); values.push({ text: phrase.text, quoted: true, start: i, end: phrase.end }); i = phrase.end; } else { let j = i; while (j < input.length && !isSpace(input[j]) && input[j] !== ",") j += 1; values.push({ text: input.slice(i, j), quoted: false, start: i, end: j }); i = j; } if (input[i] !== ",") return { values, end: i }; i += 1; } }; const KEY = /^[A-Za-z_]+:/; export const tokenize = ( input: string, qualifiers: readonly string[] = QUALIFIERS ): QueryToken[] => { const tokens: QueryToken[] = []; let i = 0; while (i < input.length) { if (isSpace(input[i])) { i += 1; continue; } const start = i; const negated = input[i] === "-" && i + 1 < input.length && !isSpace(input[i + 1]); const body = negated ? i + 1 : i; if (input[body] === '"') { const phrase = scanPhrase(input, body); tokens.push({ kind: "phrase", negated, start, end: phrase.end, text: phrase.text }); i = phrase.end; continue; } const key = KEY.exec(input.slice(body))?.[0]; if (key) { const name = key.slice(0, -1).toLowerCase(); const { values, end } = scanValues(input, body + key.length); tokens.push({ kind: "qualifier", negated, start, end, key: name, keyEnd: body + key.length, values, known: qualifiers.includes(name) }); i = end; continue; } let end = body; while (end < input.length && !isSpace(input[end])) end += 1; tokens.push({ kind: "word", negated, start, end, text: input.slice(body, end) }); i = end; } return tokens; }; const STATE_VALUES: Record = { open: "open", closed: "closed", completed: "completed", merged: "completed" }; export const SORT_VALUES = new Set([ "relevance", "best-match", "created-desc", "created", "newest", "created-asc", "oldest" ]); /** the problem with a value or null if the value is valid */ export const valueProblem = (key: QualifierKey, value: string): string | null => { const v = value.trim().toLowerCase(); if (v === "") return `\`${key}:\` needs a value`; const ok = (() => { switch (key) { case "is": return v in STATE_VALUES; case "sort": return SORT_VALUES.has(v); case "label": case "author": return true; } })(); return ok ? null : `\`${key}:${value}\` is not valid`; }; /** the positive states in the query or null if there are none */ export const statesOf = (query: string): TicketState[] | null => { const states = new Set(); for (const token of tokenize(query)) { if (token.kind !== "qualifier" || token.negated) continue; if (token.key !== "is") continue; for (const value of token.values) { const state = STATE_VALUES[value.text.trim().toLowerCase()]; if (state) states.add(state); } } return states.size > 0 ? [...states] : null; }; /** the active tab state if the query has one state only */ export const activeState = (query: string): TicketState | null => { const states = statesOf(query); return states?.length === 1 ? states[0] : null; }; const isStateClause = (token: QueryToken) => token.kind === "qualifier" && token.key === "is" && token.values.some((value) => value.text.trim().toLowerCase() in STATE_VALUES); const join = (parts: string[]) => parts.filter(Boolean).join(" "); /** removes all state clauses and keeps the remaining text */ export const withoutStates = (query: string): string => { let out = ""; let cursor = 0; for (const token of tokenize(query)) { if (!isStateClause(token)) continue; out += query.slice(cursor, token.start); cursor = token.end; } return (out + query.slice(cursor)).replace(/\s+/g, " ").trim(); }; /** the query for a state tab */ export const withState = (query: string, state: TicketState, isPull = false): string => join([`is:${state === "completed" && isPull ? "merged" : state}`, withoutStates(query)]); /** adds quotes to a value that contains spaces or commas */ export const quoteValue = (value: string): string => value === "" || /[\s,"\\]/.test(value) ? `"${value.replace(/["\\]/g, "\\$&")}"` : value; export const DEFAULT_QUERY = "is:open"; /** replaces author handles with dids and returns the handles that did not resolve */ export const resolveAuthors = async ( query: string, resolve: (handle: string) => Promise ): Promise<{ query: string; unresolved: string[] }> => { const handles = tokenize(query) .flatMap((token) => token.kind === "qualifier" && token.key === "author" ? token.values : [] ) .map((value) => ({ value, handle: normalizeHandle(value.text) })) .filter(({ handle }) => handle !== "" && !handle.startsWith("did:")); const dids = await Promise.all(handles.map(({ handle }) => resolve(handle))); const unresolved: string[] = []; let out = ""; let cursor = 0; handles.forEach(({ value, handle }, index) => { const did = dids[index]; if (!did) { unresolved.push(handle); return; } out += query.slice(cursor, value.start) + did; cursor = value.end; }); return { query: out + query.slice(cursor), unresolved }; }; /** reads the query from q or from the old state and label params */ export const queryFromUrl = (url: URL): string => { const q = url.searchParams.get("q"); if (q !== null) return q.trim(); const parts: string[] = []; const state = url.searchParams.get("state"); parts.push(state && state in STATE_VALUES ? `is:${state}` : DEFAULT_QUERY); for (const label of url.searchParams.getAll("label")) parts.push(`label:${quoteValue(label)}`); return join(parts); }; export const labelQuery = (name: string): string => join([DEFAULT_QUERY, `label:${quoteValue(name)}`]); /** the list link for a label */ export const labelHref = (listHref: string, name: string): string => `${listHref}?q=${encodeURIComponent(labelQuery(name))}`; export interface Suggestion { /** the text in the list */ label: string; /** the text that replaces the range */ insert: string; hint?: string; /** a key keeps the caret after the colon */ kind: "key" | "value"; } export interface Suggestions { from: number; to: number; items: Suggestion[]; } export interface ValueOption { value: string; hint?: string; } export interface QueryDialect { qualifiers: readonly K[]; hints: Readonly>; values(key: K, ctx: SuggestContext): ValueOption[]; problem(key: K, value: string): string | null; } const KEY_HINTS: Record = { is: "state", label: "label name", author: "handle or did", sort: "order" }; export interface SuggestContext { labels?: readonly string[]; isPull?: boolean; } export const SORT_OPTIONS: ValueOption[] = [ { value: "newest", hint: "created, newest first" }, { value: "oldest", hint: "created, oldest first" }, { value: "relevance", hint: "best match first" } ]; const valuesFor = (key: QualifierKey, ctx: SuggestContext): ValueOption[] => { const states = ctx.isPull ? [{ value: "open" }, { value: "merged" }, { value: "closed" }] : [{ value: "open" }, { value: "closed" }]; switch (key) { case "is": return states; case "label": return (ctx.labels ?? []).map((value) => ({ value })); case "sort": return SORT_OPTIONS; case "author": return []; } }; const MAX_SUGGESTIONS = 8; export const TICKET_DIALECT: QueryDialect = { qualifiers: QUALIFIERS, hints: KEY_HINTS, values: valuesFor, problem: valueProblem }; /** the suggestions for the token at the caret */ export const suggest = ( query: string, caret: number, ctx: SuggestContext = {}, dialect: QueryDialect = TICKET_DIALECT ): Suggestions | null => { const keyItem = (key: string): Suggestion => ({ label: `${key}:`, insert: `${key}:`, hint: dialect.hints[key], kind: "key" }); const token = tokenize(query, dialect.qualifiers).find( (t) => t.start <= caret && caret <= t.end ); if (!token || token.kind === "phrase") { if (token) return null; return { from: caret, to: caret, items: dialect.qualifiers.map(keyItem) }; } const body = token.negated ? token.start + 1 : token.start; if (token.kind === "word") { const prefix = query.slice(body, caret).toLowerCase(); if (prefix === "" || !/^[a-z_]+$/.test(prefix)) return null; const items = dialect.qualifiers.filter((key) => key.startsWith(prefix)).map(keyItem); return items.length > 0 ? { from: body, to: token.end, items } : null; } if (!token.known || caret < token.keyEnd) return null; const value = token.values.find((v) => v.start <= caret && caret <= v.end); if (!value || value.quoted) return null; const prefix = query.slice(value.start, caret).toLowerCase(); const taken = new Set(token.values.filter((v) => v !== value).map((v) => v.text.toLowerCase())); const items = dialect .values(token.key, ctx) .filter( ({ value: v }) => v.toLowerCase().startsWith(prefix) && v.toLowerCase() !== prefix && !taken.has(v.toLowerCase()) ) .slice(0, MAX_SUGGESTIONS) .map(({ value: v, hint }) => ({ label: v, insert: quoteValue(v), hint, kind: "value" as const })); return items.length > 0 ? { from: value.start, to: value.end, items } : null; }; /** applies a suggestion and returns the new text and caret */ export const accept = ( query: string, suggestions: Suggestions, item: Suggestion ): { query: string; caret: number } => { const before = query.slice(0, suggestions.from); let after = query.slice(suggestions.to); let insert = item.insert; if (item.kind === "value") { if (after === "") insert += " "; else if (!/^[\s,]/.test(after)) after = ` ${after}`; } return { query: before + insert + after, caret: before.length + insert.length }; };