Something went wrong. Try again.
Monorepo for Tangled tangled.org
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388import { 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<string, TicketState> = { 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<TicketState>(); 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<string | undefined>): 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<K extends string = string> { qualifiers: readonly K[]; hints: Readonly<Record<K, string>>; values(key: K, ctx: SuggestContext): ValueOption[]; problem(key: K, value: string): string | null;}
const KEY_HINTS: Record<QualifierKey, string> = { 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<QualifierKey> = { 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 };};