SPB Git forge

spb/websensor

Public
33commits 1branches 0releases
3.4 MBsize
maindefault branch
10 days agolast push
TypeScript 55.4% Python 43.2% SQL 1.2%
6.3 KB · 181 lines typescript
Raw Blame History
1/**2 * Advanced search syntax (spec §30). Browser-safe.3 *4 *   openai pricing entity:org_openai type:pricing_change,terms_change after:2026-09-01 before:2026-09-105 *   silent:true first_party:true confirmed:true importance:>70 confidence:>=50 signal:>806 *   source:openai category:ai country:CA lang:fr class:pricing group:security cluster:clu_…7 *8 * Quoted values are supported: source:"bank of canada". Unknown keys stay in the free-text part.9 */10export interface ParsedSearch {11  text: string;12  filters: {13    entity?: string;14    source?: string;15    domain?: string;16    event_type?: string;17    group?: string;18    category?: string;19    country?: string;20    language?: string;21    after?: string;22    before?: string;23    silent_change?: boolean;24    first_party?: boolean;25    confirmed?: boolean;26    importance_min?: number;27    confidence_min?: number;28    signal_min?: number;29    change_class?: string;30    cluster?: string;31    sensor?: string;32    url?: string;33  };34  tokens: { key: string; value: string }[];35}3637const KEYS = new Set(["entity", "source", "domain", "type", "event_type", "group", "category", "cat", "country", "lang", "language", "after", "since", "before", "until", "silent", "first_party", "firstparty", "confirmed", "importance", "imp", "confidence", "conf", "signal", "class", "cluster", "sensor", "url", "is"]);3839function bool(v: string): boolean | undefined {40  const s = v.toLowerCase();41  if (["true", "1", "yes", "y"].includes(s)) return true;42  if (["false", "0", "no", "n"].includes(s)) return false;43  return undefined;44}4546function num(v: string): number | undefined {47  const m = v.match(/^(?:>=|>|=)?\s*(\d+(?:\.\d+)?)$/);48  return m ? Number(m[1]) : undefined;49}5051/** Relative dates: 1h, 6h, 24h, 7d, 30d → ISO timestamp. */52function date(v: string): string | undefined {53  const rel = v.match(/^(\d+)\s*([hdwm])$/i);54  if (rel) {55    const n = Number(rel[1]);56    const unit = rel[2]!.toLowerCase();57    const ms = unit === "h" ? 3600e3 : unit === "d" ? 86400e3 : unit === "w" ? 7 * 86400e3 : 30 * 86400e3;58    return new Date(Date.now() - n * ms).toISOString();59  }60  const d = new Date(v.length === 10 ? `${v}T00:00:00Z` : v);61  return Number.isNaN(d.getTime()) ? undefined : d.toISOString();62}6364export function parseSearch(input: string): ParsedSearch {65  const tokens: { key: string; value: string }[] = [];66  const free: string[] = [];67  const re = /(\w+):("([^"]*)"|'([^']*)'|(\S+))|("([^"]*)")|(\S+)/g;68  let m: RegExpExecArray | null;69  while ((m = re.exec(input)) !== null) {70    if (m[1] && KEYS.has(m[1].toLowerCase())) tokens.push({ key: m[1].toLowerCase(), value: (m[3] ?? m[4] ?? m[5] ?? "").trim() });71    else free.push(m[7] ?? m[8] ?? m[0]);72  }73  const f: ParsedSearch["filters"] = {};74  for (const { key, value } of tokens) {75    switch (key) {76      case "entity":77        f.entity = /^(org|prd|ent)_/.test(value) ? value : `org_${value.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`;78        break;79      case "source":80        f.source = value.toLowerCase().replace(/[^a-z0-9.-]+/g, "-");81        break;82      case "domain":83        f.domain = value.toLowerCase();84        break;85      case "type":86      case "event_type":87        f.event_type = value;88        break;89      case "group":90        f.group = value.toLowerCase();91        break;92      case "category":93      case "cat":94        f.category = value.toLowerCase();95        break;96      case "country":97        f.country = value.toUpperCase();98        break;99      case "lang":100      case "language":101        f.language = value.toLowerCase();102        break;103      case "after":104      case "since":105        f.after = date(value);106        break;107      case "before":108      case "until":109        f.before = date(value);110        break;111      case "silent":112        f.silent_change = bool(value);113        break;114      case "first_party":115      case "firstparty":116        f.first_party = bool(value);117        break;118      case "confirmed":119        f.confirmed = bool(value);120        break;121      case "importance":122      case "imp":123        f.importance_min = num(value);124        break;125      case "confidence":126      case "conf":127        f.confidence_min = num(value);128        break;129      case "signal":130        f.signal_min = num(value);131        break;132      case "class":133        f.change_class = value.toLowerCase();134        break;135      case "cluster":136        f.cluster = value;137        break;138      case "sensor":139        f.sensor = value;140        break;141      case "url":142        f.url = value;143        break;144      case "is":145        if (value === "silent") f.silent_change = true;146        else if (value === "breaking") f.importance_min = Math.max(f.importance_min ?? 0, 80);147        else if (value === "confirmed") f.confirmed = true;148        else if (value === "first-party" || value === "firstparty") f.first_party = true;149        break;150    }151  }152  return { text: free.join(" ").trim(), filters: f, tokens };153}154155/** Inverse: build a query string from filters (for saved views / URL sync). */156export function stringifySearch(p: { text?: string; filters: ParsedSearch["filters"] }): string {157  const parts: string[] = [];158  if (p.text) parts.push(p.text);159  const f = p.filters;160  const q = (v: string): string => (/\s/.test(v) ? `"${v}"` : v);161  if (f.entity) parts.push(`entity:${q(f.entity)}`);162  if (f.source) parts.push(`source:${q(f.source)}`);163  if (f.domain) parts.push(`domain:${f.domain}`);164  if (f.event_type) parts.push(`type:${f.event_type}`);165  if (f.group) parts.push(`group:${f.group}`);166  if (f.category) parts.push(`category:${f.category}`);167  if (f.country) parts.push(`country:${f.country}`);168  if (f.language) parts.push(`lang:${f.language}`);169  if (f.after) parts.push(`after:${f.after.slice(0, 10)}`);170  if (f.before) parts.push(`before:${f.before.slice(0, 10)}`);171  if (f.silent_change !== undefined) parts.push(`silent:${f.silent_change}`);172  if (f.first_party !== undefined) parts.push(`first_party:${f.first_party}`);173  if (f.confirmed !== undefined) parts.push(`confirmed:${f.confirmed}`);174  if (f.importance_min !== undefined) parts.push(`importance:>${f.importance_min}`);175  if (f.confidence_min !== undefined) parts.push(`confidence:>${f.confidence_min}`);176  if (f.signal_min !== undefined) parts.push(`signal:>${f.signal_min}`);177  if (f.change_class) parts.push(`class:${f.change_class}`);178  if (f.cluster) parts.push(`cluster:${f.cluster}`);179  return parts.join(" ");180}181