Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 36 additions & 11 deletions src-tauri/src/preview.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,9 +55,7 @@ fn http_host_allowed(host: &str) -> bool {
}
match host.parse::<std::net::IpAddr>() {
Ok(ip) => match ip {
std::net::IpAddr::V4(v4) => {
v4.is_loopback() || v4.is_private() || v4.is_link_local()
}
std::net::IpAddr::V4(v4) => v4.is_loopback() || v4.is_private() || v4.is_link_local(),
std::net::IpAddr::V6(v6) => v6.is_loopback(),
},
Err(_) => false,
Expand Down Expand Up @@ -95,7 +93,9 @@ fn build_url(host: &str, path: &str) -> Result<reqwest::Url, String> {
"http" => {
let h = url.host_str().unwrap_or_default();
if !http_host_allowed(h) {
return Err(format!("plain http is only allowed for local hosts, not {h}"));
return Err(format!(
"plain http is only allowed for local hosts, not {h}"
));
}
}
s => return Err(format!("unsupported scheme: {s}")),
Expand Down Expand Up @@ -162,7 +162,9 @@ pub async fn fetch_labelary_preview(
return Ok(PreviewFetchResult::Network);
}
if !status.is_success() {
return Ok(PreviewFetchResult::Api { status: status.as_u16() });
return Ok(PreviewFetchResult::Api {
status: status.as_u16(),
});
}
if res.content_length().is_some_and(|l| l > MAX_BYTES as u64) {
return Ok(PreviewFetchResult::TooLarge);
Expand Down Expand Up @@ -232,12 +234,20 @@ mod tests {

#[test]
fn accepts_the_labelary_print_path() {
assert!(build_url("https://api.labelary.com", "/v1/printers/8dpmm/labels/3.937x1.969/0/").is_ok());
assert!(build_url(
"https://api.labelary.com",
"/v1/printers/8dpmm/labels/3.937x1.969/0/"
)
.is_ok());
}

#[test]
fn rejects_foreign_paths_and_schemes() {
assert!(build_url("https://api.labelary.com", "/v1/printers/8dpmm/labels/1x1/0/../../steal").is_err());
assert!(build_url(
"https://api.labelary.com",
"/v1/printers/8dpmm/labels/1x1/0/../../steal"
)
.is_err());
assert!(build_url("https://api.labelary.com", "/anything").is_err());
assert!(build_url("ftp://api.labelary.com", "/v1/printers/8dpmm/labels/1x1/0/").is_err());
}
Expand All @@ -253,7 +263,10 @@ mod tests {

#[test]
fn normalize_host_trims_slash_space_and_case() {
assert_eq!(normalize_host(" https://API.Labelary.com/ "), "https://api.labelary.com");
assert_eq!(
normalize_host(" https://API.Labelary.com/ "),
"https://api.labelary.com"
);
}

#[test]
Expand All @@ -262,16 +275,28 @@ mod tests {
assert!(host_allowed("http://192.168.1.5", "192.168.1.5", false));
assert!(host_allowed("http://localhost:9090", "localhost", false));
// Custom remote host only with a bound key; bare relay is refused.
assert!(!host_allowed("https://attacker.example", "attacker.example", false));
assert!(host_allowed("https://custom.example.com", "custom.example.com", true));
assert!(!host_allowed(
"https://attacker.example",
"attacker.example",
false
));
assert!(host_allowed(
"https://custom.example.com",
"custom.example.com",
true
));
}

#[test]
fn plain_http_is_local_only() {
assert!(build_url("http://127.0.0.1:8080", "/v1/printers/8dpmm/labels/1x1/0/").is_ok());
assert!(build_url("http://192.168.1.20", "/v1/printers/8dpmm/labels/1x1/0/").is_ok());
assert!(build_url("http://localhost:9090", "/v1/printers/8dpmm/labels/1x1/0/").is_ok());
assert!(build_url("http://api.labelary.com", "/v1/printers/8dpmm/labels/1x1/0/").is_err());
assert!(build_url(
"http://api.labelary.com",
"/v1/printers/8dpmm/labels/1x1/0/"
)
.is_err());
assert!(build_url("http://8.8.8.8", "/v1/printers/8dpmm/labels/1x1/0/").is_err());
}
}
74 changes: 74 additions & 0 deletions src/components/Variables/MappingEditor.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
// @vitest-environment jsdom
import { describe, it, expect, afterEach } from 'vitest';
import { render, cleanup, act, fireEvent } from '@testing-library/react';
import { VariableMappingModal } from './VariableMappingModal';
import { useLabelStore } from '../../store/labelStore';
import { fallbackTranslations as en } from '../../locales';

afterEach(cleanup);

// A db dataset binds directly against fetched headers/rows (no raw-text cache),
// so it exercises the extracted mapping core without the CSV decode path.
const DB_SOURCE = {
kind: 'db' as const,
profileId: 'p1',
profileName: 'Local',
table: 't',
fetchedAt: '2026-01-01T00:00:00Z',
truncated: false,
rowCount: 2,
};

function seed() {
act(() => {
useLabelStore.setState({
variables: [{ id: 'v1', name: 'sku', fnNumber: 1, defaultValue: '' }],
columnMapping: null,
dataset: {
headers: ['sku', 'price'],
rows: [
['A1', '9.99'],
['B2', '4.50'],
],
source: DB_SOURCE,
activeRowIndex: 0,
},
} as never);
});
}

describe('MappingEditor via VariableMappingModal', () => {
it('renders the draft table and auto-suggests the matching column', () => {
seed();
const { container, getByText } = render(
<VariableMappingModal onClose={() => undefined} onImportCsv={() => undefined} />,
);
// The variable row shows its name and the sample from the active row.
expect(container.querySelector<HTMLInputElement>('input[value="sku"]')).not.toBeNull();
getByText('A1');
});

it('applies the draft mapping to the store on confirm', () => {
seed();
const { getByText } = render(
<VariableMappingModal onClose={() => undefined} onImportCsv={() => undefined} />,
);
act(() => {
fireEvent.click(getByText(en.variables.csvApply));
});
// The name-matched column auto-suggests, so confirm commits sku -> "sku".
const mapping = useLabelStore.getState().columnMapping;
expect(mapping?.bindings.v1).toBe('sku');
expect(mapping?.headerSnapshot).toEqual(['sku', 'price']);
});

it('shows the import-first shell when there is no dataset', () => {
act(() => {
useLabelStore.setState({ variables: [], columnMapping: null, dataset: null } as never);
});
const { getByText } = render(
<VariableMappingModal onClose={() => undefined} onImportCsv={() => undefined} />,
);
getByText(en.variables.csvNoCsvLoaded);
});
});
194 changes: 194 additions & 0 deletions src/components/Variables/MappingEditor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
import { PlusIcon } from '@heroicons/react/16/solid';
import { useT } from '../../hooks/useT';
import { CollapsibleSection } from '../ui/CollapsibleSection';
import { inputCls } from '../Properties/styles';
import { Select } from '../ui/Select';
import { Tooltip } from '../ui/Tooltip';
import { MappingRow } from './MappingRow';
import type { DraftOptions, MappingDraft } from './useMappingDraft';

/** Chrome (the modal, later the wizard step) wraps this and drives its footer
* from `draft.canApply` / `draft.apply`. */
export function MappingEditor({ draft }: { draft: MappingDraft }) {
const tv = useT().variables;
const {
csvSource,
draftVariables,
draftOptions,
setDraftOptions,
draftRow,
setDraftRow,
virtualRows,
allSlotsTaken,
showMismatchWarning,
parseError,
addVariable,
} = draft;

return (
<div className="flex flex-col gap-4">
<p className="font-mono text-[10px] text-muted leading-relaxed">{tv.csvMappingHint}</p>

{showMismatchWarning && (
<p className="font-mono text-[10px] text-amber-400 leading-relaxed">
{tv.csvHeaderMismatchWarning}
</p>
)}

{parseError && <p className="font-mono text-[10px] text-amber-400">{tv.csvParseError}</p>}

<div className="flex flex-col border border-border/50 rounded">
<div className="max-h-72 overflow-y-auto">
<table className="w-full text-xs font-mono">
<thead className="sticky top-0 bg-surface z-10">
<tr className="text-left text-muted uppercase text-[10px] tracking-wider">
<th className="pb-2 pt-2 px-3 font-medium">{tv.csvVariableHeader}</th>
<th className="pb-2 pt-2 pr-3 font-medium">{tv.csvColumnHeader}</th>
<th className="pb-2 pt-2 pr-3 font-medium">{tv.csvSampleHeader}</th>
</tr>
</thead>
<tbody>
{draftVariables.length === 0 ? (
<tr>
<td colSpan={3} className="py-3 px-3 text-muted italic text-[10px]">
{tv.csvNoVariables}
</td>
</tr>
) : (
draftVariables.map((v) => <MappingRow key={v.id} draft={draft} variable={v} />)
)}
</tbody>
</table>
</div>
<div className="border-t border-border/50 px-3 py-2 flex flex-col gap-2">
<button
onClick={addVariable}
disabled={allSlotsTaken}
className="self-start flex items-center gap-1.5 px-2 py-1 rounded text-xs font-mono border border-dashed border-border text-muted hover:text-text hover:border-border-2 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
<PlusIcon className="w-3.5 h-3.5" />
{tv.add}
</button>
</div>
</div>

{virtualRows.length > 0 && (
<Tooltip content={tv.csvActiveRowTooltip} className="w-full">
<div className="flex w-full items-center gap-2 font-mono text-xs text-text">
<label htmlFor="variable-mapping-preview-row" className="text-muted">
{tv.csvActiveRowLabel}:
</label>
<input
id="variable-mapping-preview-row"
type="number"
min={1}
max={virtualRows.length}
// Inline className instead of inputCls because inputCls includes
// w-full, which would crowd out the inline label and "of N" suffix.
className="w-20 bg-surface-2 border border-border rounded px-2 py-1 text-xs font-mono text-text focus:border-accent focus:outline-none"
value={draftRow + 1}
onChange={(e) => {
const n = parseInt(e.target.value, 10);
if (!Number.isNaN(n)) {
setDraftRow(Math.max(0, Math.min(n - 1, virtualRows.length - 1)));
}
}}
/>
<span className="text-muted">
{tv.csvActiveRowOf} {virtualRows.length}
</span>
</div>
</Tooltip>
)}

{csvSource && (
<CollapsibleSection
id="variable-mapping-csv-options"
title={tv.csvOptionsTitle}
defaultOpen={false}
>
<CsvOptionsEditor value={draftOptions} onChange={setDraftOptions} />
</CollapsibleSection>
)}
</div>
);
}

function CsvOptionsEditor({
value,
onChange,
}: {
value: DraftOptions;
onChange: (next: DraftOptions) => void;
}) {
const tv = useT().variables;
return (
<div className="flex flex-col gap-2 pt-2">
<div className="flex flex-col gap-1">
<label className="font-mono text-[10px] text-muted uppercase tracking-wider">
{tv.csvDelimiterLabel}
</label>
<Select<string>
value={value.delimiter}
onChange={(delimiter) => onChange({ ...value, delimiter })}
groups={[
{
options: [
{ value: '', label: tv.csvDelimiterAuto },
{ value: ',', label: tv.csvDelimiterComma },
{ value: ';', label: tv.csvDelimiterSemicolon },
{ value: '\t', label: tv.csvDelimiterTab },
],
},
]}
/>
</div>

<label className="flex items-center gap-2 font-mono text-[10px] text-text cursor-pointer">
<input
type="checkbox"
className="accent-accent"
checked={value.hasHeaderRow}
onChange={(e) => onChange({ ...value, hasHeaderRow: e.target.checked })}
/>
{tv.csvHasHeaderRow}
</label>

<div className="flex flex-col gap-1">
<label className="font-mono text-[10px] text-muted uppercase tracking-wider">
{tv.csvSkipRowsLabel}
</label>
<input
type="number"
min={0}
className={inputCls}
value={value.skipRows}
onChange={(e) => {
const n = parseInt(e.target.value, 10);
onChange({ ...value, skipRows: Math.max(0, Number.isNaN(n) ? 0 : n) });
}}
/>
</div>

<div className="flex flex-col gap-1">
<label className="font-mono text-[10px] text-muted uppercase tracking-wider">
{tv.csvEncodingLabel}
</label>
<Select<string>
value={value.encoding}
onChange={(encoding) => onChange({ ...value, encoding })}
groups={[
{
options: [
{ value: 'utf-8', label: tv.csvEncodingUtf8 },
{ value: 'windows-1252', label: tv.csvEncodingWin1252 },
{ value: 'iso-8859-1', label: tv.csvEncodingIso88591 },
{ value: 'utf-16le', label: tv.csvEncodingUtf16le },
],
},
]}
/>
</div>
</div>
);
}
Loading