diff --git a/app/Http/Controllers/ImportController.php b/app/Http/Controllers/ImportController.php index 196ad6e..c552989 100644 --- a/app/Http/Controllers/ImportController.php +++ b/app/Http/Controllers/ImportController.php @@ -171,12 +171,27 @@ public function columns(Request $request, Import $import, CsvImportService $csv) { $validated = $request->validate([ 'has_header' => 'nullable|boolean', + 'delimiter' => 'nullable|string|max:4', ]); $hasHeader = array_key_exists('has_header', $validated) ? (bool) $validated['has_header'] : (bool) ($import->meta['has_header'] ?? true); + // Resolve delimiter preference: explicit param > template meta > existing meta > auto-detect + $explicitDelimiter = null; + if (array_key_exists('delimiter', $validated) && $validated['delimiter'] !== null && $validated['delimiter'] !== '') { + $explicitDelimiter = (string) $validated['delimiter']; + } elseif ($import->import_template_id) { + // Try reading template meta for a default delimiter + $tplDelimiter = optional(ImportTemplate::find($import->import_template_id))->meta['delimiter'] ?? null; + if ($tplDelimiter) { + $explicitDelimiter = (string) $tplDelimiter; + } + } elseif (!empty($import->meta['forced_delimiter'] ?? null)) { + $explicitDelimiter = (string) $import->meta['forced_delimiter']; + } + // Only implement CSV/TSV detection for now; others can be added later if (!in_array($import->source_type, ['csv','txt'])) { return response()->json([ @@ -186,13 +201,21 @@ public function columns(Request $request, Import $import, CsvImportService $csv) } $fullPath = Storage::disk($import->disk)->path($import->path); - [$delimiter, $columns] = $csv->detectColumnsFromCsv($fullPath, $hasHeader); + if ($explicitDelimiter !== null && $explicitDelimiter !== '') { + $columns = $csv->parseColumnsFromCsv($fullPath, $explicitDelimiter, $hasHeader); + $delimiter = $explicitDelimiter; + } else { + [$delimiter, $columns] = $csv->detectColumnsFromCsv($fullPath, $hasHeader); + } // Save meta $meta = $import->meta ?? []; $meta['has_header'] = $hasHeader; $meta['detected_delimiter'] = $delimiter; $meta['columns'] = $columns; + if ($explicitDelimiter) { + $meta['forced_delimiter'] = $explicitDelimiter; + } $import->update([ 'meta' => $meta, 'status' => $import->status === 'uploaded' ? 'parsed' : $import->status, @@ -336,6 +359,7 @@ public function show(Import $import) 'import_templates.source_type', 'import_templates.default_record_type', 'import_templates.client_id', + 'import_templates.meta', 'clients.uuid as client_uuid', ]); diff --git a/app/Http/Controllers/ImportTemplateController.php b/app/Http/Controllers/ImportTemplateController.php index 43531d7..88cb909 100644 --- a/app/Http/Controllers/ImportTemplateController.php +++ b/app/Http/Controllers/ImportTemplateController.php @@ -227,8 +227,20 @@ public function update(Request $request, ImportTemplate $template) 'client_id' => 'nullable|integer|exists:clients,id', 'is_active' => 'boolean', 'sample_headers' => 'nullable|array', + 'meta' => 'nullable|array', + 'meta.delimiter' => 'nullable|string|max:4', ])->validate(); + // Merge meta safely, preserving existing keys when not provided + $newMeta = $template->meta ?? []; + if (array_key_exists('meta', $data) && is_array($data['meta'])) { + $newMeta = array_merge($newMeta, $data['meta']); + // Drop empty delimiter to allow auto-detect + if (array_key_exists('delimiter', $newMeta) && (!is_string($newMeta['delimiter']) || trim((string) $newMeta['delimiter']) === '')) { + unset($newMeta['delimiter']); + } + } + $template->update([ 'name' => $data['name'], 'description' => $data['description'] ?? null, @@ -237,6 +249,7 @@ public function update(Request $request, ImportTemplate $template) 'client_id' => $data['client_id'] ?? null, 'is_active' => $data['is_active'] ?? $template->is_active, 'sample_headers' => $data['sample_headers'] ?? $template->sample_headers, + 'meta' => $newMeta, ]); return redirect()->route('importTemplates.edit', ['template' => $template->uuid]) diff --git a/app/Services/CsvImportService.php b/app/Services/CsvImportService.php index e654f02..b54b336 100644 --- a/app/Services/CsvImportService.php +++ b/app/Services/CsvImportService.php @@ -62,4 +62,30 @@ public function detectColumnsFromCsv(string $path, bool $hasHeader): array return [$bestDelim, $clean]; } + + /** + * Parse columns from CSV using a specific delimiter. If $hasHeader is false, + * returns positional indices instead of header names. + */ + public function parseColumnsFromCsv(string $path, string $delimiter, bool $hasHeader): array + { + $firstLine = $this->readFirstLine($path); + if ($firstLine === null) { + return []; + } + $row = str_getcsv($firstLine, $delimiter); + $count = is_array($row) ? count($row) : 0; + if ($hasHeader) { + return array_map(function ($v) { + $v = trim((string) $v); + $v = preg_replace('/\s+/', ' ', $v); + return $v; + }, $row ?: []); + } + $cols = []; + for ($i = 0; $i < $count; $i++) { + $cols[] = (string) $i; + } + return $cols; + } } diff --git a/app/Services/ImportProcessor.php b/app/Services/ImportProcessor.php index 70c915a..c65bfc6 100644 --- a/app/Services/ImportProcessor.php +++ b/app/Services/ImportProcessor.php @@ -58,7 +58,11 @@ public function process(Import $import, ?Authenticatable $user = null): array ->get(['source_column', 'target_field', 'transform', 'apply_mode', 'options']); $header = $import->meta['columns'] ?? null; - $delimiter = $import->meta['detected_delimiter'] ?? ','; + // Prefer explicitly chosen delimiter, then template meta, else detected + $delimiter = $import->meta['forced_delimiter'] + ?? optional($import->template)->meta['delimiter'] + ?? $import->meta['detected_delimiter'] + ?? ','; $hasHeader = (bool) ($import->meta['has_header'] ?? true); $path = Storage::disk($import->disk)->path($import->path); diff --git a/resources/js/Pages/Imports/Import.vue b/resources/js/Pages/Imports/Import.vue index 15b6fcd..da9c8d2 100644 --- a/resources/js/Pages/Imports/Import.vue +++ b/resources/js/Pages/Imports/Import.vue @@ -25,6 +25,28 @@ const savingMappings = ref(false); // Persisted mappings from backend (raw view regardless of detected columns) const persistedMappings = ref([]); const detectedNote = ref(''); +// Delimiter selection (auto by default, can be overridden by template or user) +const delimiterState = ref({ mode: 'auto', custom: '' }); +const effectiveDelimiter = computed(() => { + switch (delimiterState.value.mode) { + case 'auto': return null; // let backend detect + case 'comma': return ','; + case 'semicolon': return ';'; + case 'tab': return '\t'; + case 'pipe': return '|'; + case 'space': return ' '; + case 'custom': return delimiterState.value.custom || null; + default: return null; + } +}); +// Initialize delimiter from import meta if previously chosen +const initForced = props.import?.meta?.forced_delimiter || null; +if (initForced) { + const map = { ',': 'comma', ';': 'semicolon', '\t': 'tab', '|': 'pipe', ' ': 'space' }; + const mode = map[initForced] || 'custom'; + delimiterState.value.mode = mode; + if (mode === 'custom') delimiterState.value.custom = initForced; +} // Logs const events = ref([]); const eventsLimit = ref(200); @@ -276,7 +298,11 @@ const selectedMappingsCount = computed(() => mappingRows.value.filter(r => !r.sk async function fetchColumns() { if (!importId.value) return; const url = route('imports.columns', { import: importId.value }); - const { data } = await axios.get(url, { params: { has_header: hasHeader.value ? 1 : 0 } }); + const params = { has_header: hasHeader.value ? 1 : 0 }; + if (effectiveDelimiter.value) { + params.delimiter = effectiveDelimiter.value; + } + const { data } = await axios.get(url, { params }); // Normalize columns to strings for consistent rendering const colsRaw = Array.isArray(data.columns) ? data.columns : []; const normCols = colsRaw.map((c) => { @@ -334,6 +360,17 @@ async function applyTemplateToImport() { withCredentials: true, }); templateApplied.value = true; + // If template has a default delimiter, adopt it and refetch columns + const tpl = selectedTemplateOption.value; + const tplDelim = tpl?.delimiter || tpl?.meta?.delimiter || null; + if (tplDelim) { + // map to known mode if possible, else set custom + const map = { ',': 'comma', ';': 'semicolon', '\t': 'tab', '|': 'pipe', ' ': 'space' }; + const mode = map[tplDelim] || 'custom'; + delimiterState.value.mode = mode; + if (mode === 'custom') delimiterState.value.custom = tplDelim; + await fetchColumns(); + } await loadImportMappings(); } catch (e) { templateApplied.value = false; @@ -520,6 +557,13 @@ watch(() => detected.value.columns, (cols) => { } }); +// If user changes delimiter selection, refresh detected columns +watch(() => delimiterState.value, async () => { + if (importId.value) { + await fetchColumns(); + } +}, { deep: true }); + async function fetchEvents() { if (!importId.value) return; loadingEvents.value = true; @@ -616,6 +660,34 @@ async function fetchEvents() { + +
+
+ + +
+
+ + +

Template default: {{ selectedTemplateOption?.meta?.delimiter || 'auto' }}

+
+
+ + +
+
+
+
+ + +

Pusti prazno za samodejno zaznavo. Uporabi, ko zaznavanje ne deluje pravilno.

+
diff --git a/tests/Feature/ImportDelimiterTest.php b/tests/Feature/ImportDelimiterTest.php new file mode 100644 index 0000000..c2f36db --- /dev/null +++ b/tests/Feature/ImportDelimiterTest.php @@ -0,0 +1,112 @@ +put($path, $csv); + + $import = Import::create([ + 'uuid' => $uuid, + 'user_id' => null, + 'import_template_id' => null, + 'client_id' => null, + 'source_type' => 'csv', + 'file_name' => basename($path), + 'original_name' => 'semicolon.csv', + 'disk' => $disk, + 'path' => $path, + 'size' => strlen($csv), + 'status' => 'uploaded', + 'meta' => [ 'has_header' => true ], + ]); + + $response = test()->getJson(route('imports.columns', ['import' => $import->id, 'has_header' => 1, 'delimiter' => ';'])); + $response->assertSuccessful(); + $data = $response->json(); + expect($data['detected_delimiter'])->toBe(';'); + expect($data['columns'])->toBe(['email','reference']); +}); + +it('processes using template default delimiter when provided', function () { + // Authenticate a user so Person::creating can set user_id + $user = User::factory()->create(); + Auth::login($user); + + // Minimal records for defaults used by ImportProcessor + DB::table('person_groups')->insert([ + 'name' => 'default', + 'description' => '', + 'color_tag' => null, + 'created_at' => now(), + 'updated_at' => now(), + ]); + PersonType::firstOrCreate(['name' => 'default'], ['description' => '']); + + // Template with semicolon as default delimiter + $template = ImportTemplate::create([ + 'uuid' => (string) Str::uuid(), + 'name' => 'Semicolon CSV', + 'source_type' => 'csv', + 'default_record_type' => null, + 'user_id' => $user->id, + 'client_id' => null, + 'is_active' => true, + 'sample_headers' => ['email','reference'], + 'meta' => [ 'delimiter' => ';' ], + ]); + + // Put a semicolon CSV file + $uuid = (string) Str::uuid(); + $disk = 'local'; + $path = "imports/{$uuid}.csv"; + $csv = "email;reference\njohn.delim@example.com;R-100\n"; + Storage::disk($disk)->put($path, $csv); + + $import = Import::create([ + 'uuid' => $uuid, + 'user_id' => $user->id, + 'import_template_id' => $template->id, + 'client_id' => null, + 'source_type' => 'csv', + 'file_name' => basename($path), + 'original_name' => 'semicol.csv', + 'disk' => $disk, + 'path' => $path, + 'size' => strlen($csv), + 'status' => 'parsed', + // columns present to allow mapping by header name + 'meta' => [ 'has_header' => true, 'columns' => ['email','reference'] ], + ]); + + // Map email -> email.value + DB::table('import_mappings')->insert([ + 'import_id' => $import->id, + 'entity' => 'emails', + 'source_column' => 'email', + 'target_field' => 'email.value', + 'transform' => 'trim', + 'apply_mode' => 'both', + 'options' => null, + 'position' => 1, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $result = (new ImportProcessor)->process($import, $user); + expect($result['ok'])->toBeTrue(); + expect(Email::where('value', 'john.delim@example.com')->exists())->toBeTrue(); +});