ensurePermission(); $templates = DocumentTemplate::query()->orderByDesc('updated_at')->get(); $actions = Action::with(['decisions:id,name'])->orderBy('name')->get(['id', 'name']); $actionsMapped = $actions->map(fn ($a) => [ 'id' => $a->id, 'name' => $a->name, 'decisions' => $a->decisions->map(fn ($d) => ['id' => $d->id, 'name' => $d->name])->values(), ]); return Inertia::render('Admin/DocumentTemplates/Index', [ 'templates' => $templates, 'actions' => $actionsMapped, ]); } public function toggleActive(DocumentTemplate $template) { $this->ensurePermission(); $template->active = ! $template->active; $template->updated_by = Auth::id(); $template->save(); return redirect()->back()->with('success', 'Status predloge posodobljen.'); } public function show(DocumentTemplate $template) { $this->ensurePermission(); return Inertia::render('Admin/DocumentTemplates/Show', [ 'template' => $template, ]); } public function edit(DocumentTemplate $template) { $this->ensurePermission(); $actions = Action::with(['decisions:id,name'])->orderBy('name')->get(['id', 'name']); $actionsMapped = $actions->map(fn ($a) => [ 'id' => $a->id, 'name' => $a->name, 'decisions' => $a->decisions->map(fn ($d) => ['id' => $d->id, 'name' => $d->name])->values(), ]); return Inertia::render('Admin/DocumentTemplates/Edit', [ 'template' => $template, 'actions' => $actionsMapped, ]); } public function updateSettings(UpdateDocumentTemplateRequest $request, DocumentTemplate $template) { $this->ensurePermission(); $template->fill($request->only([ 'output_filename_pattern', 'date_format', 'action_id', 'decision_id', 'activity_note_template', ])); // If both action & decision provided, ensure decision belongs to action (parity with import templates) if ($request->filled('action_id') && $request->filled('decision_id')) { $belongs = \DB::table('action_decision') ->where('action_id', $request->integer('action_id')) ->where('decision_id', $request->integer('decision_id')) ->exists(); if (! $belongs) { return redirect()->back()->withErrors(['decision_id' => 'Izbrana odločitev ne pripada izbrani akciji.']); } } elseif ($request->filled('action_id') && ! $request->filled('decision_id')) { // Allow clearing decision when action changes if ($template->isDirty('action_id')) { $template->decision_id = null; } } if ($request->has('fail_on_unresolved')) { $template->fail_on_unresolved = (bool) $request->boolean('fail_on_unresolved'); } // Build formatting options array from discrete fields if provided $fmt = $template->formatting_options ?? []; $dirty = false; foreach ([ 'number_decimals', 'decimal_separator', 'thousands_separator', 'currency_symbol', 'currency_position', ] as $key) { if ($request->filled($key)) { $fmt[$key] = $request->input($key); $dirty = true; } elseif ($request->has($key) && $request->input($key) === null) { unset($fmt[$key]); $dirty = true; } } if ($request->has('currency_space')) { $fmt['currency_space'] = (bool) $request->boolean('currency_space'); $dirty = true; } if ($request->filled('default_date_format')) { $fmt['default_date_format'] = $request->input('default_date_format'); $dirty = true; } if ($request->has('date_formats')) { $fmt['date_formats'] = array_filter((array) $request->input('date_formats'), fn ($v) => $v !== null && $v !== ''); $dirty = true; } if ($dirty) { $template->formatting_options = $fmt; } // Merge meta, including custom_defaults if ($request->has('meta') && is_array($request->input('meta'))) { $meta = array_filter($request->input('meta'), fn ($v) => $v !== null && $v !== ''); $template->meta = array_replace($template->meta ?? [], $meta); } $template->updated_by = Auth::id(); $template->save(); return redirect()->back()->with('success', 'Nastavitve predloge shranjene.'); } public function rescanTokens(DocumentTemplate $template) { $this->ensurePermission(); // Best-effort: read stored DOCX from disk and re-scan tokens $tokens = []; try { /** @var TokenScanner $scanner */ $scanner = app(TokenScanner::class); $zip = new \ZipArchive; $tmp = tempnam(sys_get_temp_dir(), 'tmpl'); // Copy file from storage to a temp path $disk = 'public'; $stream = \Storage::disk($disk)->get($template->file_path); file_put_contents($tmp, $stream); if ($zip->open($tmp) === true) { // Collect main document and header/footer parts $parts = []; for ($i = 0; $i < $zip->numFiles; $i++) { $stat = $zip->statIndex($i); $name = $stat['name'] ?? ''; if (preg_match('#^word\/(document|header\d+|footer\d+)\.xml$#i', $name)) { $parts[] = $name; } } if (empty($parts)) { $parts = ['word/document.xml']; } $found = []; foreach ($parts as $name) { $xml = $zip->getFromName($name); if ($xml === false) { continue; } $norm = self::normalizeDocxXmlTokens($xml); $det = $scanner->scan($norm); if (! empty($det)) { $found = array_merge($found, $det); } } $tokens = array_values(array_unique($found)); $zip->close(); } } catch (\Throwable $e) { // swallow scanning errors, keep $tokens as empty } if (\Schema::hasColumn('document_templates', 'tokens')) { $template->tokens = $tokens; } // Auto-detect custom.* tokens and ensure meta.custom_default_types has defaults try { $meta = is_array($template->meta) ? $template->meta : []; $types = isset($meta['custom_default_types']) && is_array($meta['custom_default_types']) ? $meta['custom_default_types'] : []; $defaults = isset($meta['custom_defaults']) && is_array($meta['custom_defaults']) ? $meta['custom_defaults'] : []; foreach (($tokens ?? []) as $tok) { if (is_string($tok) && str_starts_with($tok, 'custom.')) { $key = substr($tok, 7); if ($key !== '') { if (! array_key_exists($key, $types)) { $types[$key] = 'string'; } if (! array_key_exists($key, $defaults)) { $defaults[$key] = ''; } } } } if (! empty($types)) { $meta['custom_default_types'] = $types; } if (! empty($defaults)) { $meta['custom_defaults'] = $defaults; } if ($meta !== ($template->meta ?? [])) { $template->meta = $meta; } } catch (\Throwable $e) { // ignore meta typing/defaults failures } $template->updated_by = Auth::id(); $template->save(); $count = is_array($tokens) ? count($tokens) : 0; return back()->with('success', "Tokens posodobljeni ({$count} najdenih)."); } public function store(StoreDocumentTemplateRequest $request) { $this->ensurePermission(); $file = $request->file('file'); // Basic extension guard (defense in depth vs only MIME detection) if (strtolower($file->getClientOriginalExtension()) !== 'docx') { return redirect()->back()->withErrors(['file' => 'Datoteka mora biti DOCX.']); } $slug = Str::slug($request->slug); // Determine next version if slug exists $latest = DocumentTemplate::where('slug', $slug)->orderByDesc('version')->first(); $nextVersion = $latest ? ($latest->version + 1) : 1; $hash = hash_file('sha256', $file->getRealPath()); $path = $file->store("document-templates/{$slug}/v{$nextVersion}", 'public'); // Scan tokens from uploaded DOCX (best effort) – normalize XML to collapse Word run boundaries $tokens = []; try { /** @var TokenScanner $scanner */ $scanner = app(TokenScanner::class); $zip = new \ZipArchive; $tmp = tempnam(sys_get_temp_dir(), 'tmpl'); copy($file->getRealPath(), $tmp); if ($zip->open($tmp) === true) { // Collect main document and header/footer parts $parts = []; for ($i = 0; $i < $zip->numFiles; $i++) { $stat = $zip->statIndex($i); $name = $stat['name'] ?? ''; if (preg_match('#^word\/(document|header\d+|footer\d+)\.xml$#i', $name)) { $parts[] = $name; } } if (empty($parts)) { $parts = ['word/document.xml']; } $found = []; foreach ($parts as $name) { $xml = $zip->getFromName($name); if ($xml === false) { continue; } $norm = self::normalizeDocxXmlTokens($xml); $det = $scanner->scan($norm); if (! empty($det)) { $found = array_merge($found, $det); } } $tokens = array_values(array_unique($found)); $zip->close(); } } catch (\Throwable $e) { // swallow scanning errors } // (Future) Could refine allowed columns automatically based on tokens $entities = ['contract', 'client_case', 'client', 'person', 'account']; $columns = [ 'contract' => ['reference', 'start_date', 'end_date', 'description'], 'client_case' => ['client_ref'], 'client' => [], 'person' => ['full_name', 'first_name', 'last_name', 'nu'], // Add common account attributes; whitelist may further refine 'account' => ['reference', 'initial_amount', 'balance_amount', 'promise_date'], ]; $payload = [ 'name' => $request->name, 'slug' => $slug, 'custom_name' => $request->custom_name, 'description' => $request->description, 'core_entity' => 'contract', 'entities' => $entities, 'columns' => $columns, 'version' => $nextVersion, 'engine' => 'tokens', 'file_path' => $path, 'file_hash' => $hash, 'file_size' => $file->getSize(), 'mime_type' => $file->getMimeType(), 'active' => true, 'created_by' => $latest ? $latest->created_by : Auth::id(), // preserve original author for lineage if re-upload 'updated_by' => Auth::id(), 'formatting_options' => [ 'number_decimals' => 2, 'decimal_separator' => ',', 'thousands_separator' => '.', 'currency_symbol' => '€', 'currency_position' => 'after', 'currency_space' => true, ], ]; // Optional meta + activity linkage fields (parity with import templates style) if ($request->filled('meta') && is_array($request->input('meta'))) { $payload['meta'] = array_filter($request->input('meta'), fn ($v) => $v !== null && $v !== ''); } if ($request->filled('action_id')) { $payload['action_id'] = $request->integer('action_id'); } if ($request->filled('decision_id')) { $payload['decision_id'] = $request->integer('decision_id'); } if ($request->filled('activity_note_template')) { $payload['activity_note_template'] = $request->input('activity_note_template'); } if (Schema::hasColumn('document_templates', 'tokens')) { $payload['tokens'] = $tokens; } // Auto-add default string types for any detected custom.* tokens try { $meta = isset($payload['meta']) && is_array($payload['meta']) ? $payload['meta'] : []; $types = isset($meta['custom_default_types']) && is_array($meta['custom_default_types']) ? $meta['custom_default_types'] : []; $defaults = isset($meta['custom_defaults']) && is_array($meta['custom_defaults']) ? $meta['custom_defaults'] : []; foreach (($tokens ?? []) as $tok) { if (is_string($tok) && str_starts_with($tok, 'custom.')) { $key = substr($tok, 7); if ($key !== '') { if (! array_key_exists($key, $types)) { $types[$key] = 'string'; } if (! array_key_exists($key, $defaults)) { $defaults[$key] = ''; } } } } if (! empty($types)) { $meta['custom_default_types'] = $types; } if (! empty($defaults)) { $meta['custom_defaults'] = $defaults; } if ($meta !== ($payload['meta'] ?? [])) { $payload['meta'] = $meta; } } catch (\Throwable $e) { // ignore meta typing/defaults failures } $template = DocumentTemplate::create($payload); return redirect()->back()->with('success', 'Predloga uspešno shranjena. (v'.$template->version.')')->with('template_id', $template->id); } private function ensurePermission(): void { if (Gate::denies('manage-document-templates') && Gate::denies('manage-settings')) { abort(403); } } /** * Collapse common Word run boundaries and proofing spans so tokens like {{client.person.full_name}} * appear contiguous in XML for scanning. */ private static function normalizeDocxXmlTokens(string $xml): string { // Remove proofing error markers $xml = preg_replace('#]*/>#i', '', $xml) ?? $xml; // Iteratively collapse boundaries between text runs, even if w:rPr is present $patterns = [ '#\s*\s*(?:]*/>\s*)*(?:]*>\s*(?:.*?\s*)*)?]*>#is', ]; $prev = null; while ($prev !== $xml) { $prev = $xml; foreach ($patterns as $pat) { $xml = preg_replace($pat, '', $xml) ?? $xml; } } // Remove zero-width and soft hyphen characters $xml = str_replace(["\xE2\x80\x8B", "\xC2\xAD"], '', $xml); return $xml; } }