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:
@@ -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