feat(registratura): subject autocomplete with inline template fields
- New subject-template-service: extracts reusable templates from existing subjects by detecting variable parts (numbers, years, text after separators) - Template input component: inline editable fields within static text (e.g., "Cerere CU nr. [___]/[____] — [___________]") - Two-tier autocomplete dropdown: templates sorted by frequency (top) + matching existing subjects (bottom) - Learns from database: more entries = better suggestions - Follows existing contact autocomplete pattern (focus/blur, onMouseDown) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -20,6 +20,15 @@ import {
|
|||||||
Link2,
|
Link2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { CompanyId } from "@/core/auth/types";
|
import type { CompanyId } from "@/core/auth/types";
|
||||||
|
import {
|
||||||
|
extractTemplates,
|
||||||
|
assembleSubject,
|
||||||
|
filterTemplates,
|
||||||
|
filterSubjects,
|
||||||
|
} from "../services/subject-template-service";
|
||||||
|
import type { SubjectTemplate } from "../services/subject-template-service";
|
||||||
|
import { SubjectTemplateInput } from "./subject-template-input";
|
||||||
|
import { SubjectAutocompleteDropdown } from "./subject-autocomplete-dropdown";
|
||||||
import type {
|
import type {
|
||||||
RegistryEntry,
|
RegistryEntry,
|
||||||
RegistryDirection,
|
RegistryDirection,
|
||||||
@@ -146,6 +155,10 @@ export function RegistryEntryForm({
|
|||||||
);
|
);
|
||||||
const [customDocType, setCustomDocType] = useState("");
|
const [customDocType, setCustomDocType] = useState("");
|
||||||
const [subject, setSubject] = useState(initial?.subject ?? "");
|
const [subject, setSubject] = useState(initial?.subject ?? "");
|
||||||
|
const [subjectQuery, setSubjectQuery] = useState(initial?.subject ?? "");
|
||||||
|
const [subjectFocused, setSubjectFocused] = useState(false);
|
||||||
|
const [activeTemplate, setActiveTemplate] = useState<SubjectTemplate | null>(null);
|
||||||
|
const [templateFieldValues, setTemplateFieldValues] = useState<Record<string, string>>({});
|
||||||
const [date, setDate] = useState(
|
const [date, setDate] = useState(
|
||||||
initial?.date ?? new Date().toISOString().slice(0, 10),
|
initial?.date ?? new Date().toISOString().slice(0, 10),
|
||||||
);
|
);
|
||||||
@@ -292,6 +305,37 @@ export function RegistryEntryForm({
|
|||||||
[filterContacts, assignee],
|
[filterContacts, assignee],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ── Subject autocomplete ──
|
||||||
|
const allSubjects = useMemo(() => {
|
||||||
|
if (!allEntries) return [];
|
||||||
|
const unique = new Set<string>();
|
||||||
|
for (const e of allEntries) {
|
||||||
|
if (e.subject && !e.isReserved) unique.add(e.subject);
|
||||||
|
}
|
||||||
|
return Array.from(unique);
|
||||||
|
}, [allEntries]);
|
||||||
|
|
||||||
|
const allTemplates = useMemo(
|
||||||
|
() => extractTemplates(allSubjects),
|
||||||
|
[allSubjects],
|
||||||
|
);
|
||||||
|
|
||||||
|
const matchingTemplates = useMemo(
|
||||||
|
() =>
|
||||||
|
subjectQuery.length >= 2
|
||||||
|
? filterTemplates(allTemplates, subjectQuery).slice(0, 5)
|
||||||
|
: [],
|
||||||
|
[allTemplates, subjectQuery],
|
||||||
|
);
|
||||||
|
|
||||||
|
const matchingSuggestions = useMemo(
|
||||||
|
() =>
|
||||||
|
subjectQuery.length >= 2
|
||||||
|
? filterSubjects(allSubjects, subjectQuery, 5)
|
||||||
|
: [],
|
||||||
|
[allSubjects, subjectQuery],
|
||||||
|
);
|
||||||
|
|
||||||
// ── Quick contact creation handler ──
|
// ── Quick contact creation handler ──
|
||||||
const openQuickContact = (
|
const openQuickContact = (
|
||||||
field: "sender" | "recipient" | "assignee",
|
field: "sender" | "recipient" | "assignee",
|
||||||
@@ -663,8 +707,8 @@ export function RegistryEntryForm({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Subject */}
|
{/* Subject with autocomplete + template mode */}
|
||||||
<div>
|
<div className="relative">
|
||||||
<Label className="flex items-center gap-1.5">
|
<Label className="flex items-center gap-1.5">
|
||||||
Subiect *
|
Subiect *
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
@@ -682,11 +726,56 @@ export function RegistryEntryForm({
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
|
||||||
value={subject}
|
{activeTemplate ? (
|
||||||
onChange={(e) => setSubject(e.target.value)}
|
<SubjectTemplateInput
|
||||||
className="mt-1"
|
template={activeTemplate}
|
||||||
required
|
fieldValues={templateFieldValues}
|
||||||
|
onFieldChange={(fieldId, value) => {
|
||||||
|
const next = { ...templateFieldValues, [fieldId]: value };
|
||||||
|
setTemplateFieldValues(next);
|
||||||
|
setSubject(assembleSubject(activeTemplate, next));
|
||||||
|
}}
|
||||||
|
onClear={() => {
|
||||||
|
setActiveTemplate(null);
|
||||||
|
setSubjectQuery(subject);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
value={subjectQuery}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSubjectQuery(e.target.value);
|
||||||
|
setSubject(e.target.value);
|
||||||
|
}}
|
||||||
|
onFocus={() => setSubjectFocused(true)}
|
||||||
|
onBlur={() => setTimeout(() => setSubjectFocused(false), 200)}
|
||||||
|
className="mt-1"
|
||||||
|
required
|
||||||
|
placeholder="Tastează pentru sugestii..."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<SubjectAutocompleteDropdown
|
||||||
|
templates={matchingTemplates}
|
||||||
|
suggestions={matchingSuggestions}
|
||||||
|
visible={
|
||||||
|
subjectFocused &&
|
||||||
|
!activeTemplate &&
|
||||||
|
subjectQuery.length >= 2 &&
|
||||||
|
(matchingTemplates.length > 0 || matchingSuggestions.length > 0)
|
||||||
|
}
|
||||||
|
onSelectTemplate={(t) => {
|
||||||
|
setActiveTemplate(t);
|
||||||
|
setTemplateFieldValues({});
|
||||||
|
setSubject(assembleSubject(t, {}));
|
||||||
|
setSubjectFocused(false);
|
||||||
|
}}
|
||||||
|
onSelectSuggestion={(s) => {
|
||||||
|
setSubject(s);
|
||||||
|
setSubjectQuery(s);
|
||||||
|
setSubjectFocused(false);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { SubjectTemplate } from "../services/subject-template-service";
|
||||||
|
|
||||||
|
interface SubjectAutocompleteDropdownProps {
|
||||||
|
templates: SubjectTemplate[];
|
||||||
|
suggestions: string[];
|
||||||
|
visible: boolean;
|
||||||
|
onSelectTemplate: (template: SubjectTemplate) => void;
|
||||||
|
onSelectSuggestion: (subject: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SubjectAutocompleteDropdown({
|
||||||
|
templates,
|
||||||
|
suggestions,
|
||||||
|
visible,
|
||||||
|
onSelectTemplate,
|
||||||
|
onSelectSuggestion,
|
||||||
|
}: SubjectAutocompleteDropdownProps) {
|
||||||
|
if (!visible || (templates.length === 0 && suggestions.length === 0)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="absolute z-10 mt-1 w-full rounded-md border bg-popover p-1 shadow-md max-h-64 overflow-y-auto">
|
||||||
|
{/* Section 1: Templates */}
|
||||||
|
{templates.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="px-2 py-1 text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">
|
||||||
|
Șabloane
|
||||||
|
</div>
|
||||||
|
{templates.map((t) => (
|
||||||
|
<button
|
||||||
|
key={t.id}
|
||||||
|
type="button"
|
||||||
|
className="w-full rounded px-2 py-1.5 text-left text-sm hover:bg-accent"
|
||||||
|
onMouseDown={() => onSelectTemplate(t)}
|
||||||
|
>
|
||||||
|
<span className="font-medium">{t.pattern}</span>
|
||||||
|
<span className="ml-2 text-muted-foreground text-xs">
|
||||||
|
({t.frequency}x)
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
{templates.length > 0 && suggestions.length > 0 && (
|
||||||
|
<div className="border-t my-1" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Section 2: Plain suggestions */}
|
||||||
|
{suggestions.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="px-2 py-1 text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">
|
||||||
|
Sugestii
|
||||||
|
</div>
|
||||||
|
{suggestions.map((s, i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
type="button"
|
||||||
|
className="w-full rounded px-2 py-1.5 text-left text-sm hover:bg-accent truncate"
|
||||||
|
onMouseDown={() => onSelectSuggestion(s)}
|
||||||
|
>
|
||||||
|
{s}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
import type { SubjectTemplate } from "../services/subject-template-service";
|
||||||
|
|
||||||
|
interface SubjectTemplateInputProps {
|
||||||
|
template: SubjectTemplate;
|
||||||
|
fieldValues: Record<string, string>;
|
||||||
|
onFieldChange: (fieldId: string, value: string) => void;
|
||||||
|
onClear: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SubjectTemplateInput({
|
||||||
|
template,
|
||||||
|
fieldValues,
|
||||||
|
onFieldChange,
|
||||||
|
onClear,
|
||||||
|
}: SubjectTemplateInputProps) {
|
||||||
|
const firstFieldRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// Auto-focus first field on mount
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => firstFieldRef.current?.focus(), 50);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
let fieldRendered = 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-wrap items-baseline gap-0.5",
|
||||||
|
"min-h-9 w-full rounded-md border border-input bg-transparent px-3 py-1.5",
|
||||||
|
"text-sm shadow-xs mt-1",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{template.tokens.map((token, i) => {
|
||||||
|
if (token.type === "static") {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={`s${i}`}
|
||||||
|
className="whitespace-nowrap text-muted-foreground"
|
||||||
|
>
|
||||||
|
{token.value}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const field = token.field!;
|
||||||
|
const isFirst = fieldRendered === 0;
|
||||||
|
fieldRendered++;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
key={field.id}
|
||||||
|
ref={isFirst ? firstFieldRef : undefined}
|
||||||
|
value={fieldValues[field.id] ?? ""}
|
||||||
|
onChange={(e) => onFieldChange(field.id, e.target.value)}
|
||||||
|
placeholder={field.placeholder}
|
||||||
|
className={cn(
|
||||||
|
"border-b-2 border-dashed border-primary/40 bg-transparent",
|
||||||
|
"text-sm font-medium text-foreground",
|
||||||
|
"px-1 py-0 outline-none",
|
||||||
|
"focus:border-primary focus:border-solid",
|
||||||
|
"placeholder:text-muted-foreground/50 placeholder:italic placeholder:text-xs",
|
||||||
|
field.width,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClear}
|
||||||
|
className="ml-auto shrink-0 text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
title="Renunță la șablon"
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
/**
|
||||||
|
* Subject Template Service — extracts reusable templates from existing
|
||||||
|
* registry subjects and provides filtering/assembly utilities.
|
||||||
|
*
|
||||||
|
* Pure functions, no React or side effects.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ── Types ──
|
||||||
|
|
||||||
|
export interface TemplateField {
|
||||||
|
id: string; // "f0", "f1", ...
|
||||||
|
name: string; // "nr", "an", "detalii"
|
||||||
|
placeholder: string; // Romanian label
|
||||||
|
width: string; // Tailwind width class
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TemplateToken {
|
||||||
|
type: "static" | "field";
|
||||||
|
value: string; // literal text for static, empty for fields
|
||||||
|
field?: TemplateField;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubjectTemplate {
|
||||||
|
id: string;
|
||||||
|
pattern: string; // "Cerere CU nr. {nr}/{an} — {detalii}"
|
||||||
|
tokens: TemplateToken[];
|
||||||
|
fields: TemplateField[];
|
||||||
|
frequency: number;
|
||||||
|
exampleSubject: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Internal helpers ──
|
||||||
|
|
||||||
|
/** Simple hash for template deduplication */
|
||||||
|
function hashPattern(s: string): string {
|
||||||
|
let h = 0;
|
||||||
|
for (let i = 0; i < s.length; i++) {
|
||||||
|
h = ((h << 5) - h + s.charCodeAt(i)) | 0;
|
||||||
|
}
|
||||||
|
return `t${Math.abs(h).toString(36)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tokenize a subject into static text + variable field segments.
|
||||||
|
*
|
||||||
|
* Strategy: scan left-to-right, replacing recognized variable patterns with
|
||||||
|
* field tokens while keeping everything else as static text.
|
||||||
|
*/
|
||||||
|
function tokenize(subject: string): TemplateToken[] {
|
||||||
|
const tokens: TemplateToken[] = [];
|
||||||
|
let fieldIdx = 0;
|
||||||
|
|
||||||
|
// First check for a separator split (— or – or - with spaces)
|
||||||
|
const sepMatch = subject.match(/^(.+?)\s([—–])\s(.+)$/);
|
||||||
|
|
||||||
|
if (sepMatch) {
|
||||||
|
const left = sepMatch[1]!;
|
||||||
|
const sep = sepMatch[2]!;
|
||||||
|
const right = sepMatch[3]!;
|
||||||
|
|
||||||
|
// Tokenize the left part (may contain nr/an)
|
||||||
|
const leftTokens = tokenizeSegment(left, fieldIdx);
|
||||||
|
tokens.push(...leftTokens.tokens);
|
||||||
|
fieldIdx = leftTokens.nextIdx;
|
||||||
|
|
||||||
|
// Add separator as static
|
||||||
|
tokens.push({ type: "static", value: ` ${sep} ` });
|
||||||
|
|
||||||
|
// Right side is always a "detalii" field
|
||||||
|
tokens.push({
|
||||||
|
type: "field",
|
||||||
|
value: "",
|
||||||
|
field: {
|
||||||
|
id: `f${fieldIdx}`,
|
||||||
|
name: "detalii",
|
||||||
|
placeholder: "detalii...",
|
||||||
|
width: "flex-1 min-w-32",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// No separator — tokenize the whole thing
|
||||||
|
const result = tokenizeSegment(subject, fieldIdx);
|
||||||
|
tokens.push(...result.tokens);
|
||||||
|
}
|
||||||
|
|
||||||
|
return tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tokenize a segment (no separator) by replacing number patterns.
|
||||||
|
*/
|
||||||
|
function tokenizeSegment(
|
||||||
|
segment: string,
|
||||||
|
startIdx: number,
|
||||||
|
): { tokens: TemplateToken[]; nextIdx: number } {
|
||||||
|
const tokens: TemplateToken[] = [];
|
||||||
|
let idx = startIdx;
|
||||||
|
|
||||||
|
// Combined regex: match year (4-digit 20xx) or other numbers (1-6 digits)
|
||||||
|
const numPattern = /\b(20\d{2})\b|\b(\d{1,6})\b/g;
|
||||||
|
|
||||||
|
let lastEnd = 0;
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
|
||||||
|
while ((match = numPattern.exec(segment)) !== null) {
|
||||||
|
// Static text before this match
|
||||||
|
if (match.index > lastEnd) {
|
||||||
|
tokens.push({ type: "static", value: segment.slice(lastEnd, match.index) });
|
||||||
|
}
|
||||||
|
|
||||||
|
const isYear = match[1] !== undefined;
|
||||||
|
tokens.push({
|
||||||
|
type: "field",
|
||||||
|
value: "",
|
||||||
|
field: {
|
||||||
|
id: `f${idx}`,
|
||||||
|
name: isYear ? "an" : "nr",
|
||||||
|
placeholder: isYear ? "an" : "nr.",
|
||||||
|
width: isYear ? "w-16" : "w-14",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
idx++;
|
||||||
|
lastEnd = match.index + match[0].length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trailing static text
|
||||||
|
if (lastEnd < segment.length) {
|
||||||
|
tokens.push({ type: "static", value: segment.slice(lastEnd) });
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no fields found, the whole segment is a single static token
|
||||||
|
if (tokens.length === 0) {
|
||||||
|
tokens.push({ type: "static", value: segment });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { tokens, nextIdx: idx };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the pattern string from tokens for grouping/display.
|
||||||
|
* E.g. "Cerere CU nr. {nr}/{an} — {detalii}"
|
||||||
|
*/
|
||||||
|
function buildPattern(tokens: TemplateToken[]): string {
|
||||||
|
return tokens
|
||||||
|
.map((t) => (t.type === "static" ? t.value : `{${t.field!.name}}`))
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Public API ──
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract reusable templates from an array of existing subjects.
|
||||||
|
* Groups by pattern, counts frequency, returns sorted by frequency desc.
|
||||||
|
*/
|
||||||
|
export function extractTemplates(subjects: string[]): SubjectTemplate[] {
|
||||||
|
if (subjects.length === 0) return [];
|
||||||
|
|
||||||
|
// Deduplicate subjects
|
||||||
|
const unique = Array.from(new Set(subjects));
|
||||||
|
|
||||||
|
// Tokenize each subject and group by pattern
|
||||||
|
const groups = new Map<
|
||||||
|
string,
|
||||||
|
{ tokens: TemplateToken[]; example: string; count: number }
|
||||||
|
>();
|
||||||
|
|
||||||
|
for (const subj of unique) {
|
||||||
|
// Skip very short subjects (no useful template)
|
||||||
|
if (subj.length < 8) continue;
|
||||||
|
|
||||||
|
const tokens = tokenize(subj);
|
||||||
|
const fields = tokens.filter((t) => t.type === "field");
|
||||||
|
|
||||||
|
// Only consider subjects that have at least one field (variable part)
|
||||||
|
if (fields.length === 0) continue;
|
||||||
|
|
||||||
|
const pattern = buildPattern(tokens);
|
||||||
|
const existing = groups.get(pattern);
|
||||||
|
if (existing) {
|
||||||
|
existing.count++;
|
||||||
|
} else {
|
||||||
|
groups.set(pattern, { tokens, example: subj, count: 1 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count actual frequency: for each pattern, count how many of the
|
||||||
|
// original (non-unique) subjects match
|
||||||
|
// (Already counting unique matches above; for frequency boost, count
|
||||||
|
// duplicates from the full input array)
|
||||||
|
const fullCounts = new Map<string, number>();
|
||||||
|
for (const subj of subjects) {
|
||||||
|
if (subj.length < 8) continue;
|
||||||
|
const tokens = tokenize(subj);
|
||||||
|
const fields = tokens.filter((t) => t.type === "field");
|
||||||
|
if (fields.length === 0) continue;
|
||||||
|
const pattern = buildPattern(tokens);
|
||||||
|
fullCounts.set(pattern, (fullCounts.get(pattern) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build result array
|
||||||
|
const result: SubjectTemplate[] = [];
|
||||||
|
|
||||||
|
for (const [pattern, group] of groups) {
|
||||||
|
const freq = fullCounts.get(pattern) ?? group.count;
|
||||||
|
|
||||||
|
// Rebuild fields array from tokens (with sequential IDs)
|
||||||
|
const fields: TemplateField[] = [];
|
||||||
|
let fIdx = 0;
|
||||||
|
const normalizedTokens: TemplateToken[] = group.tokens.map((t) => {
|
||||||
|
if (t.type === "field" && t.field) {
|
||||||
|
const field: TemplateField = { ...t.field, id: `f${fIdx}` };
|
||||||
|
fields.push(field);
|
||||||
|
fIdx++;
|
||||||
|
return { ...t, field };
|
||||||
|
}
|
||||||
|
return t;
|
||||||
|
});
|
||||||
|
|
||||||
|
result.push({
|
||||||
|
id: hashPattern(pattern),
|
||||||
|
pattern,
|
||||||
|
tokens: normalizedTokens,
|
||||||
|
fields,
|
||||||
|
frequency: freq,
|
||||||
|
exampleSubject: group.example,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by frequency desc, limit to 15
|
||||||
|
result.sort((a, b) => b.frequency - a.frequency);
|
||||||
|
return result.slice(0, 15);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assemble a final subject string from a template + field values.
|
||||||
|
*/
|
||||||
|
export function assembleSubject(
|
||||||
|
template: SubjectTemplate,
|
||||||
|
fieldValues: Record<string, string>,
|
||||||
|
): string {
|
||||||
|
return template.tokens
|
||||||
|
.map((t) => {
|
||||||
|
if (t.type === "static") return t.value;
|
||||||
|
return fieldValues[t.field!.id] ?? "";
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter templates by a query string.
|
||||||
|
* Matches against both the pattern and the example subject.
|
||||||
|
*/
|
||||||
|
export function filterTemplates(
|
||||||
|
templates: SubjectTemplate[],
|
||||||
|
query: string,
|
||||||
|
): SubjectTemplate[] {
|
||||||
|
if (!query || query.length < 2) return [];
|
||||||
|
const q = query.toLowerCase();
|
||||||
|
return templates.filter(
|
||||||
|
(t) =>
|
||||||
|
t.pattern.toLowerCase().includes(q) ||
|
||||||
|
t.exampleSubject.toLowerCase().includes(q),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter existing subjects by query (plain text substring match).
|
||||||
|
* Returns unique matches, limited to `limit`.
|
||||||
|
*/
|
||||||
|
export function filterSubjects(
|
||||||
|
subjects: string[],
|
||||||
|
query: string,
|
||||||
|
limit = 5,
|
||||||
|
): string[] {
|
||||||
|
if (!query || query.length < 2) return [];
|
||||||
|
const q = query.toLowerCase();
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const result: string[] = [];
|
||||||
|
for (const s of subjects) {
|
||||||
|
if (result.length >= limit) break;
|
||||||
|
const lower = s.toLowerCase();
|
||||||
|
if (lower.includes(q) && !seen.has(lower)) {
|
||||||
|
seen.add(lower);
|
||||||
|
result.push(s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user