feat(word-templates): placeholder auto-detection from .docx via JSZip

This commit is contained in:
AI Assistant
2026-02-19 07:02:12 +02:00
parent 67fd88813a
commit 713a66bcd9
4 changed files with 529 additions and 101 deletions

View File

@@ -1,45 +1,91 @@
'use client';
"use client";
import { useState } from 'react';
import { Plus, Pencil, Trash2, Search, FileText, ExternalLink, Copy } from 'lucide-react';
import { Button } from '@/shared/components/ui/button';
import { Input } from '@/shared/components/ui/input';
import { Label } from '@/shared/components/ui/label';
import { Textarea } from '@/shared/components/ui/textarea';
import { Badge } from '@/shared/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/components/ui/card';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/components/ui/select';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/shared/components/ui/dialog';
import type { CompanyId } from '@/core/auth/types';
import type { WordTemplate, TemplateCategory } from '../types';
import { useTemplates } from '../hooks/use-templates';
import { useRef, useState } from "react";
import {
Plus,
Pencil,
Trash2,
Search,
FileText,
ExternalLink,
Copy,
FolderOpen,
Wand2,
Loader2,
} from "lucide-react";
import { Button } from "@/shared/components/ui/button";
import { Input } from "@/shared/components/ui/input";
import { Label } from "@/shared/components/ui/label";
import { Textarea } from "@/shared/components/ui/textarea";
import { Badge } from "@/shared/components/ui/badge";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/shared/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/shared/components/ui/dialog";
import type { CompanyId } from "@/core/auth/types";
import type { WordTemplate, TemplateCategory } from "../types";
import { useTemplates } from "../hooks/use-templates";
import {
parsePlaceholdersFromBuffer,
parsePlaceholdersFromUrl,
} from "../services/placeholder-parser";
const CATEGORY_LABELS: Record<TemplateCategory, string> = {
contract: 'Contract',
memoriu: 'Memoriu tehnic',
oferta: 'Ofertă',
raport: 'Raport',
cerere: 'Cerere',
aviz: 'Aviz',
scrisoare: 'Scrisoare',
altele: 'Altele',
contract: "Contract",
memoriu: "Memoriu tehnic",
oferta: "Ofertă",
raport: "Raport",
cerere: "Cerere",
aviz: "Aviz",
scrisoare: "Scrisoare",
altele: "Altele",
};
type ViewMode = 'list' | 'add' | 'edit';
type ViewMode = "list" | "add" | "edit";
export function WordTemplatesModule() {
const { templates, allTemplates, loading, filters, updateFilter, addTemplate, updateTemplate, cloneTemplate, removeTemplate } = useTemplates();
const [viewMode, setViewMode] = useState<ViewMode>('list');
const [editingTemplate, setEditingTemplate] = useState<WordTemplate | null>(null);
const {
templates,
allTemplates,
loading,
filters,
updateFilter,
addTemplate,
updateTemplate,
cloneTemplate,
removeTemplate,
} = useTemplates();
const [viewMode, setViewMode] = useState<ViewMode>("list");
const [editingTemplate, setEditingTemplate] = useState<WordTemplate | null>(
null,
);
const [deletingId, setDeletingId] = useState<string | null>(null);
const handleSubmit = async (data: Omit<WordTemplate, 'id' | 'createdAt' | 'updatedAt'>) => {
if (viewMode === 'edit' && editingTemplate) {
const handleSubmit = async (
data: Omit<WordTemplate, "id" | "createdAt" | "updatedAt">,
) => {
if (viewMode === "edit" && editingTemplate) {
await updateTemplate(editingTemplate.id, data);
} else {
await addTemplate(data);
}
setViewMode('list');
setViewMode("list");
setEditingTemplate(null);
};
@@ -54,30 +100,80 @@ export function WordTemplatesModule() {
<div className="space-y-6">
{/* Stats */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<Card><CardContent className="p-4"><p className="text-xs text-muted-foreground">Total șabloane</p><p className="text-2xl font-bold">{allTemplates.length}</p></CardContent></Card>
<Card><CardContent className="p-4"><p className="text-xs text-muted-foreground">Beletage</p><p className="text-2xl font-bold">{allTemplates.filter((t) => t.company === 'beletage').length}</p></CardContent></Card>
<Card><CardContent className="p-4"><p className="text-xs text-muted-foreground">Urban Switch</p><p className="text-2xl font-bold">{allTemplates.filter((t) => t.company === 'urban-switch').length}</p></CardContent></Card>
<Card><CardContent className="p-4"><p className="text-xs text-muted-foreground">Studii de Teren</p><p className="text-2xl font-bold">{allTemplates.filter((t) => t.company === 'studii-de-teren').length}</p></CardContent></Card>
<Card>
<CardContent className="p-4">
<p className="text-xs text-muted-foreground">Total șabloane</p>
<p className="text-2xl font-bold">{allTemplates.length}</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<p className="text-xs text-muted-foreground">Beletage</p>
<p className="text-2xl font-bold">
{allTemplates.filter((t) => t.company === "beletage").length}
</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<p className="text-xs text-muted-foreground">Urban Switch</p>
<p className="text-2xl font-bold">
{allTemplates.filter((t) => t.company === "urban-switch").length}
</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<p className="text-xs text-muted-foreground">Studii de Teren</p>
<p className="text-2xl font-bold">
{
allTemplates.filter((t) => t.company === "studii-de-teren")
.length
}
</p>
</CardContent>
</Card>
</div>
{viewMode === 'list' && (
{viewMode === "list" && (
<>
<div className="flex flex-wrap items-center gap-3">
<div className="relative min-w-[200px] flex-1">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input placeholder="Caută șablon..." value={filters.search} onChange={(e) => updateFilter('search', e.target.value)} className="pl-9" />
<Input
placeholder="Caută șablon..."
value={filters.search}
onChange={(e) => updateFilter("search", e.target.value)}
className="pl-9"
/>
</div>
<Select value={filters.category} onValueChange={(v) => updateFilter('category', v as TemplateCategory | 'all')}>
<SelectTrigger className="w-[160px]"><SelectValue /></SelectTrigger>
<Select
value={filters.category}
onValueChange={(v) =>
updateFilter("category", v as TemplateCategory | "all")
}
>
<SelectTrigger className="w-[160px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Toate categoriile</SelectItem>
{(Object.keys(CATEGORY_LABELS) as TemplateCategory[]).map((c) => (
<SelectItem key={c} value={c}>{CATEGORY_LABELS[c]}</SelectItem>
))}
{(Object.keys(CATEGORY_LABELS) as TemplateCategory[]).map(
(c) => (
<SelectItem key={c} value={c}>
{CATEGORY_LABELS[c]}
</SelectItem>
),
)}
</SelectContent>
</Select>
<Select value={filters.company} onValueChange={(v) => updateFilter('company', v)}>
<SelectTrigger className="w-[150px]"><SelectValue /></SelectTrigger>
<Select
value={filters.company}
onValueChange={(v) => updateFilter("company", v)}
>
<SelectTrigger className="w-[150px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Toate companiile</SelectItem>
<SelectItem value="beletage">Beletage</SelectItem>
@@ -86,28 +182,51 @@ export function WordTemplatesModule() {
<SelectItem value="group">Grup</SelectItem>
</SelectContent>
</Select>
<Button onClick={() => setViewMode('add')} className="shrink-0">
<Button onClick={() => setViewMode("add")} className="shrink-0">
<Plus className="mr-1.5 h-4 w-4" /> Adaugă
</Button>
</div>
{loading ? (
<p className="py-8 text-center text-sm text-muted-foreground">Se încarcă...</p>
<p className="py-8 text-center text-sm text-muted-foreground">
Se încarcă...
</p>
) : templates.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">Niciun șablon găsit. Adaugă primul șablon Word.</p>
<p className="py-8 text-center text-sm text-muted-foreground">
Niciun șablon găsit. Adaugă primul șablon Word.
</p>
) : (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{templates.map((tpl) => (
<Card key={tpl.id} className="group relative">
<CardContent className="p-4">
<div className="absolute right-2 top-2 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100">
<Button variant="ghost" size="icon" className="h-7 w-7" title="Clonează" onClick={() => cloneTemplate(tpl.id)}>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
title="Clonează"
onClick={() => cloneTemplate(tpl.id)}
>
<Copy className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => { setEditingTemplate(tpl); setViewMode('edit'); }}>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => {
setEditingTemplate(tpl);
setViewMode("edit");
}}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" onClick={() => setDeletingId(tpl.id)}>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive"
onClick={() => setDeletingId(tpl.id)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
@@ -117,22 +236,42 @@ export function WordTemplatesModule() {
</div>
<div className="min-w-0">
<p className="font-medium">{tpl.name}</p>
{tpl.description && <p className="mt-0.5 text-xs text-muted-foreground line-clamp-2">{tpl.description}</p>}
{tpl.description && (
<p className="mt-0.5 text-xs text-muted-foreground line-clamp-2">
{tpl.description}
</p>
)}
<div className="mt-1.5 flex flex-wrap gap-1">
<Badge variant="outline" className="text-[10px]">{CATEGORY_LABELS[tpl.category]}</Badge>
<Badge variant="secondary" className="text-[10px]">v{tpl.version}</Badge>
{tpl.clonedFrom && <Badge variant="secondary" className="text-[10px]">Clonă</Badge>}
<Badge variant="outline" className="text-[10px]">
{CATEGORY_LABELS[tpl.category]}
</Badge>
<Badge variant="secondary" className="text-[10px]">
v{tpl.version}
</Badge>
{tpl.clonedFrom && (
<Badge variant="secondary" className="text-[10px]">
Clonă
</Badge>
)}
</div>
{/* Placeholders display */}
{(tpl.placeholders ?? []).length > 0 && (
<div className="mt-1.5 flex flex-wrap gap-1">
{(tpl.placeholders ?? []).map((p) => (
<span key={p} className="rounded bg-muted px-1 py-0.5 font-mono text-[10px] text-muted-foreground">{`{{${p}}}`}</span>
<span
key={p}
className="rounded bg-muted px-1 py-0.5 font-mono text-[10px] text-muted-foreground"
>{`{{${p}}}`}</span>
))}
</div>
)}
{tpl.fileUrl && (
<a href={tpl.fileUrl} target="_blank" rel="noopener noreferrer" className="mt-1 inline-flex items-center gap-1 text-xs text-primary hover:underline">
<a
href={tpl.fileUrl}
target="_blank"
rel="noopener noreferrer"
className="mt-1 inline-flex items-center gap-1 text-xs text-primary hover:underline"
>
<ExternalLink className="h-3 w-3" /> Deschide fișier
</a>
)}
@@ -146,23 +285,48 @@ export function WordTemplatesModule() {
</>
)}
{(viewMode === 'add' || viewMode === 'edit') && (
{(viewMode === "add" || viewMode === "edit") && (
<Card>
<CardHeader><CardTitle>{viewMode === 'edit' ? 'Editare șablon' : 'Șablon nou'}</CardTitle></CardHeader>
<CardHeader>
<CardTitle>
{viewMode === "edit" ? "Editare șablon" : "Șablon nou"}
</CardTitle>
</CardHeader>
<CardContent>
<TemplateForm initial={editingTemplate ?? undefined} onSubmit={handleSubmit} onCancel={() => { setViewMode('list'); setEditingTemplate(null); }} />
<TemplateForm
initial={editingTemplate ?? undefined}
onSubmit={handleSubmit}
onCancel={() => {
setViewMode("list");
setEditingTemplate(null);
}}
/>
</CardContent>
</Card>
)}
{/* Delete confirmation */}
<Dialog open={deletingId !== null} onOpenChange={(open) => { if (!open) setDeletingId(null); }}>
<Dialog
open={deletingId !== null}
onOpenChange={(open) => {
if (!open) setDeletingId(null);
}}
>
<DialogContent>
<DialogHeader><DialogTitle>Confirmare ștergere</DialogTitle></DialogHeader>
<p className="text-sm">Ești sigur vrei ștergi acest șablon? Acțiunea este ireversibilă.</p>
<DialogHeader>
<DialogTitle>Confirmare ștergere</DialogTitle>
</DialogHeader>
<p className="text-sm">
Ești sigur vrei ștergi acest șablon? Acțiunea este
ireversibilă.
</p>
<DialogFooter>
<Button variant="outline" onClick={() => setDeletingId(null)}>Anulează</Button>
<Button variant="destructive" onClick={handleDeleteConfirm}>Șterge</Button>
<Button variant="outline" onClick={() => setDeletingId(null)}>
Anulează
</Button>
<Button variant="destructive" onClick={handleDeleteConfirm}>
Șterge
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -170,50 +334,151 @@ export function WordTemplatesModule() {
);
}
function TemplateForm({ initial, onSubmit, onCancel }: {
function TemplateForm({
initial,
onSubmit,
onCancel,
}: {
initial?: WordTemplate;
onSubmit: (data: Omit<WordTemplate, 'id' | 'createdAt' | 'updatedAt'>) => void;
onSubmit: (
data: Omit<WordTemplate, "id" | "createdAt" | "updatedAt">,
) => void;
onCancel: () => void;
}) {
const [name, setName] = useState(initial?.name ?? '');
const [description, setDescription] = useState(initial?.description ?? '');
const [category, setCategory] = useState<TemplateCategory>(initial?.category ?? 'contract');
const [fileUrl, setFileUrl] = useState(initial?.fileUrl ?? '');
const [company, setCompany] = useState<CompanyId>(initial?.company ?? 'beletage');
const [version, setVersion] = useState(initial?.version ?? '1.0.0');
const [placeholdersText, setPlaceholdersText] = useState((initial?.placeholders ?? []).join(', '));
const [name, setName] = useState(initial?.name ?? "");
const [description, setDescription] = useState(initial?.description ?? "");
const [category, setCategory] = useState<TemplateCategory>(
initial?.category ?? "contract",
);
const [fileUrl, setFileUrl] = useState(initial?.fileUrl ?? "");
const [company, setCompany] = useState<CompanyId>(
initial?.company ?? "beletage",
);
const [version, setVersion] = useState(initial?.version ?? "1.0.0");
const [placeholdersText, setPlaceholdersText] = useState(
(initial?.placeholders ?? []).join(", "),
);
const [parsing, setParsing] = useState(false);
const [parseError, setParseError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const applyPlaceholders = (found: string[]) => {
if (found.length === 0) {
setParseError(
"Nu s-au găsit placeholder-e de forma {{VARIABILA}} în fișier.",
);
return;
}
setPlaceholdersText(found.join(", "));
setParseError(null);
};
const handleFileDetect = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setParsing(true);
setParseError(null);
try {
const buffer = await file.arrayBuffer();
const found = await parsePlaceholdersFromBuffer(buffer);
applyPlaceholders(found);
} catch (err) {
setParseError(
`Eroare la parsare: ${err instanceof Error ? err.message : String(err)}`,
);
} finally {
setParsing(false);
if (fileInputRef.current) fileInputRef.current.value = "";
}
};
const handleUrlDetect = async () => {
if (!fileUrl) return;
setParsing(true);
setParseError(null);
try {
const found = await parsePlaceholdersFromUrl(fileUrl);
applyPlaceholders(found);
} catch (err) {
setParseError(
`Nu s-a putut accesa URL-ul (CORS sau rețea): ${err instanceof Error ? err.message : String(err)}`,
);
} finally {
setParsing(false);
}
};
return (
<form onSubmit={(e) => {
e.preventDefault();
const placeholders = placeholdersText
.split(',')
.map((p) => p.trim())
.filter((p) => p.length > 0);
onSubmit({
name, description, category, fileUrl, company, version, placeholders,
clonedFrom: initial?.clonedFrom,
tags: initial?.tags ?? [], visibility: initial?.visibility ?? 'all',
});
}} className="space-y-4">
<form
onSubmit={(e) => {
e.preventDefault();
const placeholders = placeholdersText
.split(",")
.map((p) => p.trim())
.filter((p) => p.length > 0);
onSubmit({
name,
description,
category,
fileUrl,
company,
version,
placeholders,
clonedFrom: initial?.clonedFrom,
tags: initial?.tags ?? [],
visibility: initial?.visibility ?? "all",
});
}}
className="space-y-4"
>
<div className="grid gap-4 sm:grid-cols-2">
<div><Label>Nume șablon *</Label><Input value={name} onChange={(e) => setName(e.target.value)} className="mt-1" required /></div>
<div><Label>Categorie</Label>
<Select value={category} onValueChange={(v) => setCategory(v as TemplateCategory)}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
<div>
<Label>Nume șablon *</Label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
className="mt-1"
required
/>
</div>
<div>
<Label>Categorie</Label>
<Select
value={category}
onValueChange={(v) => setCategory(v as TemplateCategory)}
>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{(Object.keys(CATEGORY_LABELS) as TemplateCategory[]).map((c) => (
<SelectItem key={c} value={c}>{CATEGORY_LABELS[c]}</SelectItem>
<SelectItem key={c} value={c}>
{CATEGORY_LABELS[c]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div><Label>Descriere</Label><Textarea value={description} onChange={(e) => setDescription(e.target.value)} rows={2} className="mt-1" /></div>
<div>
<Label>Descriere</Label>
<Textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={2}
className="mt-1"
/>
</div>
<div className="grid gap-4 sm:grid-cols-3">
<div><Label>Companie</Label>
<Select value={company} onValueChange={(v) => setCompany(v as CompanyId)}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
<div>
<Label>Companie</Label>
<Select
value={company}
onValueChange={(v) => setCompany(v as CompanyId)}
>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="beletage">Beletage</SelectItem>
<SelectItem value="urban-switch">Urban Switch</SelectItem>
@@ -222,17 +487,94 @@ function TemplateForm({ initial, onSubmit, onCancel }: {
</SelectContent>
</Select>
</div>
<div><Label>Versiune</Label><Input value={version} onChange={(e) => setVersion(e.target.value)} className="mt-1" /></div>
<div><Label>URL fișier</Label><Input value={fileUrl} onChange={(e) => setFileUrl(e.target.value)} className="mt-1" placeholder="https://..." /></div>
<div>
<Label>Versiune</Label>
<Input
value={version}
onChange={(e) => setVersion(e.target.value)}
className="mt-1"
/>
</div>
<div>
<Label>URL fișier</Label>
<div className="mt-1 flex gap-1.5">
<Input
value={fileUrl}
onChange={(e) => setFileUrl(e.target.value)}
className="flex-1"
placeholder="https://..."
/>
<Button
type="button"
variant="outline"
size="icon"
title="Detectează placeholder-e din URL"
disabled={!fileUrl || parsing}
onClick={handleUrlDetect}
>
{parsing ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Wand2 className="h-4 w-4" />
)}
</Button>
</div>
</div>
</div>
<div>
<Label>Placeholder-e (separate prin virgulă)</Label>
<Input value={placeholdersText} onChange={(e) => setPlaceholdersText(e.target.value)} className="mt-1" placeholder="NUME_BENEFICIAR, DATA_CONTRACT, NR_PROIECT..." />
<p className="mt-1 text-xs text-muted-foreground">Variabilele din șablon, de forma {'{{VARIABILA}}'}, separate prin virgulă.</p>
<div className="flex items-center justify-between">
<Label>Placeholder-e detectate</Label>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">
sau detectează automat:
</span>
<input
ref={fileInputRef}
type="file"
accept=".docx,application/vnd.openxmlformats-officedocument.wordprocessingml.document"
className="hidden"
onChange={handleFileDetect}
/>
<Button
type="button"
variant="outline"
size="sm"
disabled={parsing}
onClick={() => fileInputRef.current?.click()}
>
{parsing ? (
<>
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />{" "}
Parsare...
</>
) : (
<>
<FolderOpen className="mr-1.5 h-3.5 w-3.5" /> Alege fișier
.docx
</>
)}
</Button>
</div>
</div>
<Input
value={placeholdersText}
onChange={(e) => setPlaceholdersText(e.target.value)}
className="mt-1"
placeholder="NUME_BENEFICIAR, DATA_CONTRACT, NR_PROIECT..."
/>
<p className="mt-1 text-xs text-muted-foreground">
Variabilele din șablon de forma {"{{VARIABILA}}"} detectate automat
sau introduse manual, separate prin virgulă.
</p>
{parseError && (
<p className="mt-1 text-xs text-destructive">{parseError}</p>
)}
</div>
<div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="outline" onClick={onCancel}>Anulează</Button>
<Button type="submit">{initial ? 'Actualizează' : 'Adaugă'}</Button>
<Button type="button" variant="outline" onClick={onCancel}>
Anulează
</Button>
<Button type="submit">{initial ? "Actualizează" : "Adaugă"}</Button>
</div>
</form>
);

View File

@@ -0,0 +1,53 @@
import JSZip from "jszip";
/**
* Parse a .docx ArrayBuffer and extract all {{placeholder}} patterns.
* A .docx is a ZIP; we scan all word/*.xml files for patterns.
*/
export async function parsePlaceholdersFromBuffer(
buffer: ArrayBuffer,
): Promise<string[]> {
const zip = await JSZip.loadAsync(buffer);
// Collect all word/ XML files (document, headers, footers, etc.)
const xmlFileNames = Object.keys(zip.files).filter(
(name) => name.startsWith("word/") && name.endsWith(".xml"),
);
const xmlContents = await Promise.all(
xmlFileNames.map((name) => {
const file = zip.files[name];
return file ? file.async("string") : Promise.resolve("");
}),
);
const combined = xmlContents.join("\n");
// Strip XML tags — placeholders may be split across <w:t> runs, so we
// also need to find patterns that cross tag boundaries. Strategy:
// 1) Try matching in raw XML first (most placeholders appear intact)
// 2) Then strip tags and try again to catch split-run cases
const rawMatches = [...combined.matchAll(/\{\{([^{}]+?)\}\}/g)].map((m) =>
(m[1] ?? "").trim(),
);
const strippedText = combined.replace(/<[^>]+>/g, "");
const strippedMatches = [...strippedText.matchAll(/\{\{([^{}]+?)\}\}/g)].map(
(m) => (m[1] ?? "").trim(),
);
const all = [...rawMatches, ...strippedMatches];
return [...new Set(all)].filter((p) => p.length > 0 && p.length < 80);
}
/**
* Fetch a URL and parse placeholders from the .docx binary.
* May fail if CORS blocks the fetch.
*/
export async function parsePlaceholdersFromUrl(url: string): Promise<string[]> {
const response = await fetch(url);
if (!response.ok)
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
const buffer = await response.arrayBuffer();
return parsePlaceholdersFromBuffer(buffer);
}