diff --git a/.gitattributes b/.gitattributes index 6a62a8d5..8e27e5d3 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,8 +1,11 @@ # Generated by scripts/gen-attribution.mjs (always LF) so `attribution:check` # diffs cleanly regardless of the checkout's core.autocrlf. The scripts emit LF, # so they must be checked out LF too or a fresh Windows clone would taint output. -THIRD-PARTY-LICENSES.md text eol=lf -THIRD-PARTY-LICENSES-RUST.md text eol=lf +# -whitespace: these reproduce crate/npm license bodies verbatim, so trailing +# whitespace inside them is source text, not ours to strip (stripping would +# also break the release regeneration diff). Exempt from `git diff --check`. +THIRD-PARTY-LICENSES.md text eol=lf -whitespace +THIRD-PARTY-LICENSES-RUST.md text eol=lf -whitespace scripts/*.mjs text eol=lf scripts/**/*.mjs text eol=lf diff --git a/README.md b/README.md index bb3d7ec1..ad0b84d4 100644 --- a/README.md +++ b/README.md @@ -94,10 +94,12 @@ Byte capture at import is deliberately conservative: when a field can't be mappe File menu → **Add page** creates a new page. With multiple pages, the control at the bottom-center of the canvas switches between them and removes them. All pages share the same dimensions; export and import handle each page as a separate label. -### Batch printing from CSV +### Batch printing from data File menu → **Import CSV data** loads a CSV. The mapping dialog pairs each Variable with a column, saved with the design. **Export batch ZPL** or **Send to Zebra Printer** then outputs one label per row. +On desktop, **Import Excel data** reads a worksheet and **Printer settings… → Data sources** pulls from a read-only SQLite/PostgreSQL/MySQL database (password in the OS keychain), through the same mapping and batch output. A design remembers its database link for one-click reload. + ### Printer settings File menu → **Printer settings…** configures label-level media and print quality, plus a Setup Script for clock, locale, encoding, and printer identity (meant to be sent once when first setting up a printer). Setup-Script values stay out of saved designs so sharing a `.zpl` or `.json` doesn't leak your printer name or locale. @@ -137,7 +139,7 @@ Both `.zpl` and `.json` round-trip cleanly. `.zpl` preserves all printable conte - Layers panel with reordering - Lossless ZPL round-trip: imported ZPL re-exports byte-for-byte, regenerating only what you edit ([import guarantees](#import-guarantees)) - Variables: bind text and barcode fields to named defaults that emit as `^FN` slots (or `^FE` inline embeds when one field references multiple variables), round-tripping with printer-side templates -- CSV batch printing: import a CSV, map columns to Variables, print or export with efficient printer-side data merge (template ships once, each row sends only its overrides) +- Batch printing: map columns to Variables from a CSV, an Excel worksheet, or a read-only database (desktop), then print or export with efficient printer-side data merge (template ships once, each row sends only its overrides) - GS1 content builder: assemble GS1 content from Application Identifiers (DataBar Expanded, GS1-128, and GS1 DataMatrix), validated per field and against GS1 combination rules - Content builder: generate typed QR/DataMatrix/Aztec content (URL, WiFi, contact, email, phone, SMS, geo) with the right encoding and escaping - EAN/UPC inline validation: live length counter, computed check-digit preview, and a GS1 prefix hint right under the content field diff --git a/packages/core/src/lib/designFile.ts b/packages/core/src/lib/designFile.ts index 8ae79c1e..9dc6d4bf 100644 --- a/packages/core/src/lib/designFile.ts +++ b/packages/core/src/lib/designFile.ts @@ -3,10 +3,11 @@ import { labelConfigSchema, type LabelConfig } from "../types/LabelConfig"; import { labelObjectBaseSchema } from "../types/LabelObject"; import { variableSchema, - csvMappingSchema, + columnMappingSchema, type Variable, - type CsvMapping, + type ColumnMapping, } from "../types/Variable"; +import { dbSourceRefSchema, type DbSourceRef } from "../types/DataSource"; import type { LabelObject } from "../types/Group"; import { blockOverlaySchema, type BlockOverlay } from "./zplOverlay/overlay"; import { visitLeavesInPages, foldSerialLeaf, bindSingleMarkerLeaf, sanitiseVariableNames, safeUniqueNameById } from "./objectTree"; @@ -26,10 +27,14 @@ export interface DesignFile { label: LabelConfig; pages: DesignFilePage[]; variables: Variable[]; - /** Optional: present only when the user has imported a CSV and set + /** Optional: present only when the user has loaded a dataset and set * up a mapping for the current design. Round-trips with the design; - * rows themselves are session-only and not part of the save. */ - csvMapping: CsvMapping | null; + * rows themselves are session-only and not part of the save. On disk + * the key stays `csvMapping` (pre-rename files keep loading). */ + columnMapping: ColumnMapping | null; + /** Optional pointer to the database the rows came from, so a reopened + * design can offer a one-click re-fetch. */ + dataSource: DbSourceRef | null; } // Two distinct shapes share the base fields: @@ -66,7 +71,10 @@ const designFileSchema = z.object({ label: labelConfigSchema, pages: z.array(pageSchema), variables: z.array(variableSchema).optional(), - csvMapping: csvMappingSchema.optional(), + csvMapping: columnMappingSchema.optional(), + // Comfort metadata only: a malformed pointer drops instead of rejecting + // the whole design (same policy as a broken overlay). + dataSource: dbSourceRefSchema.optional().catch(undefined), }); export function parseDesignFile(text: string): Result { @@ -93,7 +101,8 @@ export function parseDesignFile(text: string): Result 0) payload.variables = variables; - if (csvMapping) payload.csvMapping = csvMapping; + if (columnMapping) payload.csvMapping = columnMapping; + if (dataSource) payload.dataSource = dataSource; return JSON.stringify(payload, null, 2); } diff --git a/packages/core/src/lib/preflight.ts b/packages/core/src/lib/preflight.ts index c8b0d5cb..1b8736e2 100644 --- a/packages/core/src/lib/preflight.ts +++ b/packages/core/src/lib/preflight.ts @@ -11,7 +11,7 @@ import { getObjectStringContent, resolveForRow, variableSubstitutions } from "./ import { gs1ModeDSharedFns, isModeDLeaf } from "./gs1ModeDFns"; import { isBlankText } from "./zebraTextLayout"; import { resolveTextMode } from "../registry/text"; -import type { CsvMapping, Variable } from "../types/Variable"; +import type { ColumnMapping, Variable } from "../types/Variable"; import type { Unit } from "./units"; import { PREFLIGHT_SEVERITY, @@ -38,8 +38,8 @@ export function suppressPristineEmpty( interface MarkerValueDeps { variables: readonly Variable[]; - csvDataset: { headers: readonly string[]; rows: readonly (readonly string[])[] } | null; - csvMapping: CsvMapping | null; + dataset: { headers: readonly string[]; rows: readonly (readonly string[])[] } | null; + columnMapping: ColumnMapping | null; } // Per-leaf cache: the row-walk over a large CSV must not rerun every render. @@ -79,8 +79,8 @@ export function markerValueFindings( if ( hit && hit.variables === deps.variables && - hit.csvDataset === deps.csvDataset && - hit.csvMapping === deps.csvMapping + hit.dataset === deps.dataset && + hit.columnMapping === deps.columnMapping ) { out.push(...hit.findings); continue; @@ -92,10 +92,10 @@ export function markerValueFindings( // badge already covers the ACTIVE substitution (defaults, or the // active row), so only the OTHER CSV rows need checking here; without // a dataset there is nothing the badge doesn't see. - const rows = deps.csvDataset && deps.csvMapping ? deps.csvDataset.rows.map((_, i) => i) : []; + const rows = deps.dataset && deps.columnMapping ? deps.dataset.rows.map((_, i) => i) : []; const details: string[] = []; for (const rowIdx of rows) { - const resolved = resolveForRow(content, rowIdx, deps.variables, deps.csvDataset, deps.csvMapping); + const resolved = resolveForRow(content, rowIdx, deps.variables, deps.dataset, deps.columnMapping); const at = rowIdx < 0 ? "defaults" : `row ${rowIdx + 1}`; const segs = parseGs1ToSegments(resolved); if (segs === null || segs.length === 0) { @@ -120,12 +120,17 @@ export function markerValueFindings( // row, else the defaults) per marker segment against the AI's // length/charset/date rules. The resolved value IS the runtime // value here, so an empty variable-AI value is a real error. - const rows = deps.csvDataset && deps.csvMapping ? deps.csvDataset.rows.map((_, i) => i) : [-1]; + // A 0-row dataset prints the defaults too, so validate them like the + // no-dataset case rather than iterating an empty row set. + const rows = + deps.dataset && deps.columnMapping && deps.dataset.rows.length > 0 + ? deps.dataset.rows.map((_, i) => i) + : [-1]; const details: string[] = []; outer: for (const rowIdx of rows) { for (const seg of segments) { if (!hasTemplateMarkers(seg.value)) continue; - const resolved = resolveForRow(seg.value, rowIdx, deps.variables, deps.csvDataset, deps.csvMapping); + const resolved = resolveForRow(seg.value, rowIdx, deps.variables, deps.dataset, deps.columnMapping); const at = rowIdx < 0 ? "defaults" : `row ${rowIdx + 1}`; const err = validateGs1SegmentResolved(seg.ai, seg.value, resolved, false); if (err) details.push(`${at}: (${seg.ai}) ${err}`); @@ -147,13 +152,13 @@ export function markerValueFindings( } else if (typed) { const parsed = parseContent(content); const errors = typedContentMarkerFindings( - parsed.type, parsed.fields, deps.variables, deps.csvDataset, deps.csvMapping, + parsed.type, parsed.fields, deps.variables, deps.dataset, deps.columnMapping, ); for (const [field, chars] of Object.entries(errors)) { findings.push(finding(leaf, "markerValueUnsafe", `${field}: ${chars}`)); } const rows = typedContentIncompleteRows( - parsed.type, parsed.fields, deps.variables, deps.csvDataset, deps.csvMapping, + parsed.type, parsed.fields, deps.variables, deps.dataset, deps.columnMapping, ); if (rows.length > 0) { const shown = rows.slice(0, 5).join(", ") + (rows.length > 5 ? ", …" : ""); @@ -169,7 +174,7 @@ export function markerValueFindings( for (const name of new Set(extractTemplateRefs(content))) { const v = byName.get(name); if (!v) continue; - if (variableSubstitutions(v, deps.csvDataset, deps.csvMapping).some((val) => val.includes(blockHazard))) { + if (variableSubstitutions(v, deps.dataset, deps.columnMapping).some((val) => val.includes(blockHazard))) { dirty.push(v.name); } } @@ -197,7 +202,7 @@ export function markerValueFindings( for (const name of new Set(extractTemplateRefs(content))) { const v = byName.get(name); if (!v || !shared.has(v.fnNumber)) continue; - if (variableSubstitutions(v, deps.csvDataset, deps.csvMapping).some((val) => val.includes(">"))) { + if (variableSubstitutions(v, deps.dataset, deps.columnMapping).some((val) => val.includes(">"))) { dirty.push(v.name); } } diff --git a/packages/core/src/lib/typedContent.ts b/packages/core/src/lib/typedContent.ts index 7c8caceb..19d7f2a4 100644 --- a/packages/core/src/lib/typedContent.ts +++ b/packages/core/src/lib/typedContent.ts @@ -11,7 +11,7 @@ import { extractTemplateRefs, hasTemplateMarkers, mapLiteralSpans } from "./fnTemplate"; import { resolveForRow, variableSubstitutions } from "./variableBinding"; -import type { CsvMapping, Variable } from "../types/Variable"; +import type { ColumnMapping, Variable } from "../types/Variable"; // The array is the single source; the union derives from it so the two can't // drift (the encode/complete switches and the modal's field Record stay @@ -95,8 +95,8 @@ export function typedContentMarkerFindings( type: ContentType, fields: ContentFields, variables: readonly Variable[], - csvDataset: { headers: readonly string[]; rows: readonly (readonly string[])[] } | null, - csvMapping: CsvMapping | null, + dataset: { headers: readonly string[]; rows: readonly (readonly string[])[] } | null, + columnMapping: ColumnMapping | null, ): Record { const byName = new Map(variables.map((v) => [v.name, v])); const out: Record = {}; @@ -106,7 +106,7 @@ export function typedContentMarkerFindings( for (const name of new Set(extractTemplateRefs(raw))) { const variable = byName.get(name); if (!variable) continue; - for (const value of variableSubstitutions(variable, csvDataset, csvMapping)) { + for (const value of variableSubstitutions(variable, dataset, columnMapping)) { const chars = markerUnsafeChars(type, key, value); if (chars) for (const c of chars.split(" ")) hits.add(c); } @@ -117,24 +117,24 @@ export function typedContentMarkerFindings( } /** Rows whose print-time substitution yields an incomplete payload (e.g. an - * empty CSV cell blanking a required WiFi SSID). With a dataset every row is - * checked; without one only the defaults ([0] returned on failure). Clock - * markers are never empty. */ + * empty CSV cell blanking a required WiFi SSID). Each row is checked, but with + * no dataset OR a 0-row one the defaults print, so those validate `-1` instead. + * Clock markers are never empty. */ export function typedContentIncompleteRows( type: ContentType, fields: ContentFields, variables: readonly Variable[], - csvDataset: { headers: readonly string[]; rows: readonly (readonly string[])[] } | null, - csvMapping: CsvMapping | null, + dataset: { headers: readonly string[]; rows: readonly (readonly string[])[] } | null, + columnMapping: ColumnMapping | null, ): number[] { if (!Object.values(fields).some(hasTemplateMarkers)) return []; - const rows = csvDataset && csvMapping ? csvDataset.rows.map((_, i) => i) : [-1]; + const rows = dataset && columnMapping && dataset.rows.length > 0 ? dataset.rows.map((_, i) => i) : [-1]; const bad: number[] = []; for (const rowIdx of rows) { const resolved = Object.fromEntries( Object.entries(fields).map(([k, v]) => [ k, - hasTemplateMarkers(v) ? resolveForRow(v, rowIdx, variables, csvDataset, csvMapping) : v, + hasTemplateMarkers(v) ? resolveForRow(v, rowIdx, variables, dataset, columnMapping) : v, ]), ); if (!isContentComplete(type, resolved)) bad.push(rowIdx + 1); diff --git a/packages/core/src/lib/variableBinding.ts b/packages/core/src/lib/variableBinding.ts index cd33e857..4ed5fc7b 100644 --- a/packages/core/src/lib/variableBinding.ts +++ b/packages/core/src/lib/variableBinding.ts @@ -1,5 +1,5 @@ import type { LabelObject } from "../types/Group"; -import { markerOf, type CsvMapping, type Variable } from "../types/Variable"; +import { markerOf, type ColumnMapping, type Variable } from "../types/Variable"; import { resolveTemplateMarkers } from "./fnTemplate"; import { channelDatesFrom, hasClockMarkers, resolveClockMarkers } from "./fcTemplate"; import { clockDatesThunk, resolveMarkerChain, type ClockResolveCtx } from "./markerResolve"; @@ -20,10 +20,10 @@ export function getObjectStringContent(obj: LabelObject): string | undefined { /** `preview` = actual print value, `schema` = `«name»` placeholder. */ export type RenderMode = "preview" | "schema"; -/** Empty CSV cells render as empty (no defaultValue fallback). */ +/** Empty dataset cells render as empty (no defaultValue fallback). */ export function resolveVariableValue( variable: Variable, - active: ActiveCsvRow | null, + active: ActiveRow | null, mode: RenderMode = "preview", ): string { if (mode === "schema") return markerOf(variable.name); @@ -35,38 +35,38 @@ export function resolveVariableValue( return active.row[idx] ?? ""; } -export type VariableSource = "csv" | "orphan" | "default"; +export type VariableSource = "bound" | "orphan" | "default"; -/** csv=bound to existing header, orphan=bound to missing header, default=unbound. */ +/** bound=header exists in the dataset, orphan=bound to missing header, default=unbound. */ export function getVariableSource( variable: Variable, - csvDataset: { headers: readonly string[] } | null, - csvMapping: CsvMapping | null, + dataset: { headers: readonly string[] } | null, + columnMapping: ColumnMapping | null, ): VariableSource { - const header = csvMapping?.bindings[variable.id]; + const header = columnMapping?.bindings[variable.id]; if (header === undefined) return "default"; - if (!csvDataset) return "default"; - return csvDataset.headers.includes(header) ? "csv" : "orphan"; + if (!dataset) return "default"; + return dataset.headers.includes(header) ? "bound" : "orphan"; } -/** False when: no CSV, schema mode, unbound, or orphan. */ +/** False when: no dataset, schema mode, unbound, or orphan. */ export function shouldShowFallbackTint( variable: Variable | undefined, - csvDataset: { headers: readonly string[] } | null, - csvMapping: CsvMapping | null, + dataset: { headers: readonly string[] } | null, + columnMapping: ColumnMapping | null, mode: RenderMode, ): boolean { if (mode !== "preview") return false; - if (!csvDataset) return false; + if (!dataset) return false; if (!variable) return false; - return getVariableSource(variable, csvDataset, csvMapping) !== "csv"; + return getVariableSource(variable, dataset, columnMapping) !== "bound"; } /** Recurse leaves through applyBindingToObject; preserves group structure. */ export function applyBindingToTree( objects: readonly T[], variables: readonly Variable[], - active: ActiveCsvRow | null, + active: ActiveRow | null, mode: RenderMode = "preview", /** Shared clock context so every leaf sees the same instant and the * same label-level ^SO offsets. */ @@ -93,25 +93,25 @@ export function applyBindingToTree( }); } -export interface ActiveCsvRow { +export interface ActiveRow { headers: readonly string[]; row: readonly string[]; - mapping: CsvMapping; + mapping: ColumnMapping; } -/** CSV column a variable is bound to, or -1 when unbound / the header is +/** Dataset column a variable is bound to, or -1 when unbound / the header is * missing from the dataset. The one place for the binding→column lookup. */ export function boundColumnIndex( variable: Pick, - csvDataset: { headers: readonly string[] } | null, - csvMapping: Pick | null, + dataset: { headers: readonly string[] } | null, + columnMapping: Pick | null, ): number { - const header = csvMapping?.bindings[variable.id]; - if (header === undefined || !csvDataset) return -1; - return csvDataset.headers.indexOf(header); + const header = columnMapping?.bindings[variable.id]; + if (header === undefined || !dataset) return -1; + return dataset.headers.indexOf(header); } -/** Print-time resolution of `raw` for a specific CSV row: clock markers via +/** Print-time resolution of `raw` for a specific dataset row: clock markers via * the primary clock at `now` (offsets shift real dates, never width or * validity), variable markers via the row's bound cell (an empty cell prints * empty) or the default when unbound / `rowIdx < 0`. Unknown markers stay @@ -120,8 +120,8 @@ export function resolveForRow( raw: string, rowIdx: number, variables: readonly Variable[], - csvDataset: { headers: readonly string[]; rows: readonly (readonly string[])[] } | null, - csvMapping: CsvMapping | null, + dataset: { headers: readonly string[]; rows: readonly (readonly string[])[] } | null, + columnMapping: ColumnMapping | null, now: Date = new Date(), ): string { let next = raw; @@ -132,26 +132,26 @@ export function resolveForRow( return resolveTemplateMarkers(next, (name) => { const v = byName.get(name); if (!v) return undefined; - if (rowIdx >= 0 && csvDataset) { - const col = boundColumnIndex(v, csvDataset, csvMapping); - if (col >= 0) return csvDataset.rows[rowIdx]?.[col] ?? ""; + if (rowIdx >= 0 && dataset) { + const col = boundColumnIndex(v, dataset, columnMapping); + if (col >= 0) return dataset.rows[rowIdx]?.[col] ?? ""; } return v.defaultValue; }); } /** Every value a variable's marker can substitute at print time: its default - * plus, when bound, all cells of its CSV column. One tested place for the + * plus, when bound, all cells of its dataset column. One tested place for the * column-lookup/row-walk that validation consumers share. */ export function variableSubstitutions( variable: Variable, - csvDataset: { headers: readonly string[]; rows: readonly (readonly string[])[] } | null, - csvMapping: CsvMapping | null, + dataset: { headers: readonly string[]; rows: readonly (readonly string[])[] } | null, + columnMapping: ColumnMapping | null, ): string[] { const out = [variable.defaultValue]; - const col = boundColumnIndex(variable, csvDataset, csvMapping); - if (col === -1 || !csvDataset) return out; - for (const row of csvDataset.rows) { + const col = boundColumnIndex(variable, dataset, columnMapping); + if (col === -1 || !dataset) return out; + for (const row of dataset.rows) { // An empty cell prints as empty (no default fallback), so it IS a // substitution; a short row's missing cell prints empty too. out.push(row[col] ?? ""); @@ -160,25 +160,25 @@ export function variableSubstitutions( } /** Null when no dataset, no mapping, or active index out of bounds. */ -export function buildActiveCsvRow( - csvDataset: { +export function buildActiveRow( + dataset: { headers: readonly string[]; rows: readonly (readonly string[])[]; activeRowIndex: number; } | null, - csvMapping: CsvMapping | null, -): ActiveCsvRow | null { - if (!csvDataset || !csvMapping) return null; - const row = csvDataset.rows[csvDataset.activeRowIndex]; + columnMapping: ColumnMapping | null, +): ActiveRow | null { + if (!dataset || !columnMapping) return null; + const row = dataset.rows[dataset.activeRowIndex]; if (!row) return null; - return { headers: csvDataset.headers, row, mapping: csvMapping }; + return { headers: dataset.headers, row, mapping: columnMapping }; } /** Identity-preserving: returns same ref when unbound or unchanged. */ export function applyBindingToObject( obj: T, variables: readonly Variable[], - active: ActiveCsvRow | null = null, + active: ActiveRow | null = null, mode: RenderMode = "preview", /** Lazy-initialised inside the clock branch. */ clock?: ClockResolveCtx, diff --git a/packages/core/src/lib/zplGenerator.ts b/packages/core/src/lib/zplGenerator.ts index e4f435e4..9e68bc23 100644 --- a/packages/core/src/lib/zplGenerator.ts +++ b/packages/core/src/lib/zplGenerator.ts @@ -314,11 +314,11 @@ export function generateBatchZpl( label: LabelConfig, objects: LabelObject[], variables: readonly Variable[], - csvDataset: { + dataset: { headers: readonly string[]; rows: readonly (readonly string[])[]; }, - csvMapping: { bindings: Record }, + columnMapping: { bindings: Record }, ): string { const baseZpl = generateZPL(label, objects, variables); // Inject after first ^XA (not at start) because ~DY/~SD preambles @@ -335,7 +335,7 @@ export function generateBatchZpl( const modeDFns = gs1ModeDExclusiveFns(objects, variables); const overrides: { fn: number; colIdx: number; transform: (s: string) => string }[] = []; for (const v of variables) { - const colIdx = boundColumnIndex(v, csvDataset, csvMapping); + const colIdx = boundColumnIndex(v, dataset, columnMapping); if (colIdx === -1) continue; // Apply the bound field's ^FD transform (QR prefix, UPC-E compaction, GS1 // escaping) to each row value, matching the single-format export so the @@ -355,7 +355,7 @@ export function generateBatchZpl( overrides.push({ fn: v.fnNumber, colIdx, transform }); } - const recallBlocks = csvDataset.rows.map((row) => { + const recallBlocks = dataset.rows.map((row) => { const lines: string[] = ['^XA', `^XF${BATCH_TEMPLATE_PATH}`]; for (const { fn, colIdx, transform } of overrides) { const value = row[colIdx] ?? ''; diff --git a/packages/core/src/types/DataSource.ts b/packages/core/src/types/DataSource.ts new file mode 100644 index 00000000..e18dcb76 --- /dev/null +++ b/packages/core/src/types/DataSource.ts @@ -0,0 +1,90 @@ +import { z } from "zod"; + +/** Persisted pointer to a live database source; only the pointer rides the + * design file, rows are session-only. */ +export const dbSourceRefSchema = z.object({ + kind: z.literal("db"), + profileId: z.string(), + /** Snapshot of the profile name for display when the profile is gone. */ + profileName: z.string(), + table: z.string(), +}); +export type DbSourceRef = z.infer; + +export interface CsvDatasetSource { + kind: "csv"; + filename: string; + importedAt: string; + encoding: string; + delimiter: string; + rowCount: number; +} + +/** Session metadata of a database fetch; extends the persisted pointer with + * facts that only exist once rows were actually pulled. */ +export interface DbDatasetSource extends DbSourceRef { + fetchedAt: string; + /** True when the connector cut the result at its row cap. */ + truncated: boolean; + rowCount: number; +} + +/** Session metadata of an Excel worksheet import (file-based like CSV, but + * already tabular like a db fetch). */ +export interface ExcelDatasetSource { + kind: "excel"; + filename: string; + sheet: string; + importedAt: string; + rowCount: number; + truncated: boolean; +} + +/** Where loaded rows came from; discriminated on `kind` so UI can show + * source-specific affordances (re-import vs. re-fetch). */ +export type DatasetSource = CsvDatasetSource | DbDatasetSource | ExcelDatasetSource; + +/** headers+rows+source triple every dataset producer hands the store. */ +export interface DatasetInput { + headers: string[]; + rows: string[][]; + source: DatasetSource; +} + +/** Display string for a db link, shared by loaded db sources and the + * design-file reconnect pointer (which has no session fetch metadata). */ +export function dbRefDisplayName(ref: DbSourceRef): string { + return `${ref.profileName} · ${ref.table}`; +} + +/** One display string per source for badges and confirm dialogs. */ +export function datasetDisplayName(source: DatasetSource): string { + switch (source.kind) { + case "db": + return dbRefDisplayName(source); + case "excel": + return `${source.filename} · ${source.sheet}`; + case "csv": + return source.filename; + default: { + const _exhaustive: never = source; + return _exhaustive; + } + } +} + +/** Identity of the loaded data (unique per import/fetch); used as a remount + * key so draft state re-seeds when the rows are replaced. */ +export function datasetTimestamp(source: DatasetSource): string { + switch (source.kind) { + case "db": + return source.fetchedAt; + case "excel": + case "csv": + return source.importedAt; + default: { + const _exhaustive: never = source; + return _exhaustive; + } + } +} diff --git a/packages/core/src/types/Variable.ts b/packages/core/src/types/Variable.ts index 0673d27c..0eaae229 100644 --- a/packages/core/src/types/Variable.ts +++ b/packages/core/src/types/Variable.ts @@ -120,7 +120,7 @@ export const csvParseOptionsPersistedSchema = z.object({ }); export type CsvParseOptionsPersisted = z.infer; -export const csvMappingSchema = z.object({ +export const columnMappingSchema = z.object({ /** variable.id -> header name. Missing entries fall back to defaultValue. */ bindings: z.record(z.string(), z.string()), /** Headers the mapping was made against; differing re-imports trigger warning. */ @@ -128,12 +128,12 @@ export const csvMappingSchema = z.object({ /** Parse options at last apply; optional for back-compat. */ parseOptions: csvParseOptionsPersistedSchema.optional(), }); -export type CsvMapping = z.infer; +export type ColumnMapping = z.infer; /** Mapping <-> headers compatibility. Headerless: column-count match. * Header-row: order-independent name-set match. */ export function isMappingCompatibleWith( - mapping: CsvMapping, + mapping: ColumnMapping, headers: readonly string[], ): boolean { const headerless = mapping.parseOptions?.hasHeaderRow === false; @@ -144,6 +144,18 @@ export function isMappingCompatibleWith( return true; } +/** parseOptions kept when a mapping moves onto a named-header (db/excel) source: + * delimiter/encoding/skipRows survive for a later CSV re-import, but hasHeaderRow + * is dropped so it can't poison {@link isMappingCompatibleWith}. */ +export function dbExcelParseOptions( + opts: CsvParseOptionsPersisted | undefined, +): CsvParseOptionsPersisted | undefined { + if (!opts) return undefined; + const { hasHeaderRow: _drop, ...rest } = opts; + void _drop; + return Object.keys(rest).length === 0 ? undefined : rest; +} + /** Loose header match: case-insensitive, collapse spaces/dashes/underscores. */ export function normalizeHeaderForMatch(s: string): string { return s.toLowerCase().replace(/[\s_-]+/g, ""); @@ -151,7 +163,7 @@ export function normalizeHeaderForMatch(s: string): string { /** variable.id -> headerName via normalizeHeaderForMatch; each header * consumed at most once, ties go to first variable. */ -export function suggestCsvMapping( +export function suggestColumnMapping( variables: readonly Variable[], headers: readonly string[], ): Record { diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index b035e9ff..e244f524 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -32,6 +32,12 @@ dependencies = [ "alloc-no-stdlib", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "android_system_properties" version = "0.1.5" @@ -210,6 +216,24 @@ dependencies = [ "system-deps", ] +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + +[[package]] +name = "atoi_simd" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a49e05797ca52e312a0c658938b7d00693ef037799ef7187678f212d7684cf" +dependencies = [ + "debug_unsafe", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -234,6 +258,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bit-set" version = "0.8.0" @@ -377,6 +407,24 @@ dependencies = [ "system-deps", ] +[[package]] +name = "calamine" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1a9acfeb1555aa8def91fe8ff208aadaea850c109968ec35ac965edbe7d210b" +dependencies = [ + "atoi_simd", + "byteorder", + "chrono", + "codepage", + "encoding_rs", + "fast-float2", + "log", + "quick-xml 0.37.5", + "serde", + "zip", +] + [[package]] name = "camino" version = "1.2.4" @@ -474,6 +522,15 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "codepage" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48f68d061bc2828ae826206326e61251aca94c1e4a5305cf52d9138639c918b4" +dependencies = [ + "encoding_rs", +] + [[package]] name = "combine" version = "4.6.7" @@ -493,6 +550,12 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "cookie" version = "0.18.1" @@ -562,6 +625,21 @@ dependencies = [ "libc", ] +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + [[package]] name = "crc32fast" version = "1.5.0" @@ -580,6 +658,15 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "crossbeam-queue" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "803d13fb3b09d88be9f4dbc29062c66b19bf7170867ceb746d2a8689bf6c7a26" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.22" @@ -690,6 +777,23 @@ dependencies = [ "zeroize", ] +[[package]] +name = "debug_unsafe" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eed2c4702fa172d1ce21078faa7c5203e69f5394d48cc436d25928394a867a2" + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + [[package]] name = "deranged" version = "0.5.8" @@ -738,7 +842,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", + "const-oid", "crypto-common", + "subtle", ] [[package]] @@ -816,13 +922,19 @@ checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89" dependencies = [ "bit-set", "cssparser", - "foldhash", + "foldhash 0.2.0", "html5ever", "precomputed-hash", "selectors", "tendril", ] +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + [[package]] name = "dpi" version = "0.1.2" @@ -874,6 +986,15 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" +dependencies = [ + "serde", +] + [[package]] name = "embed-resource" version = "3.0.11" @@ -894,6 +1015,15 @@ version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "endi" version = "1.1.1" @@ -948,6 +1078,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "etcetera" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +dependencies = [ + "cfg-if", + "home", + "windows-sys 0.48.0", +] + [[package]] name = "event-listener" version = "5.4.1" @@ -969,6 +1110,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "fast-float2" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8eb564c5c7423d25c886fb561d1e4ee69f72354d16918afa32c08811f6b6a55" + [[package]] name = "fastrand" version = "2.4.1" @@ -1018,6 +1165,18 @@ checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ "crc32fast", "miniz_oxide", + "zlib-rs", +] + +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "spin", ] [[package]] @@ -1026,6 +1185,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "foldhash" version = "0.2.0" @@ -1075,6 +1240,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -1094,6 +1260,17 @@ dependencies = [ "futures-util", ] +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + [[package]] name = "futures-io" version = "0.3.32" @@ -1449,12 +1626,32 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.1.5", +] + [[package]] name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + [[package]] name = "heck" version = "0.4.1" @@ -1479,6 +1676,33 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "html5ever" version = "0.38.0" @@ -1958,6 +2182,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + [[package]] name = "libappindicator" version = "0.9.0" @@ -2007,13 +2240,33 @@ dependencies = [ "winapi", ] +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "libredox" version = "0.1.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" dependencies = [ + "bitflags 2.13.0", "libc", + "plain", + "redox_syscall 0.9.0", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", ] [[package]] @@ -2063,6 +2316,16 @@ dependencies = [ "web_atoms", ] +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + [[package]] name = "memchr" version = "2.8.2" @@ -2162,12 +2425,48 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand", + "smallvec", + "zeroize", +] + [[package]] name = "num-conv" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -2175,6 +2474,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -2528,11 +2828,20 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall", + "redox_syscall 0.5.18", "smallvec", "windows-link 0.2.1", ] +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -2609,12 +2918,39 @@ dependencies = [ "futures-io", ] +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "pkg-config" version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + [[package]] name = "plist" version = "1.10.0" @@ -2623,7 +2959,7 @@ checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85" dependencies = [ "base64 0.22.1", "indexmap 2.14.0", - "quick-xml", + "quick-xml 0.41.0", "serde", "time", ] @@ -2683,6 +3019,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "precomputed-hash" version = "0.1.1" @@ -2760,6 +3105,16 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quick-xml" +version = "0.37.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" +dependencies = [ + "encoding_rs", + "memchr", +] + [[package]] name = "quick-xml" version = "0.41.0" @@ -2790,6 +3145,36 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + [[package]] name = "raw-window-handle" version = "0.6.2" @@ -2805,6 +3190,15 @@ dependencies = [ "bitflags 2.13.0", ] +[[package]] +name = "redox_syscall" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5102a6aaa05aa011a238e178e6bca86d2cb56fc9f586d37cb80f5bca6e07759" +dependencies = [ + "bitflags 2.13.0", +] + [[package]] name = "redox_users" version = "0.5.2" @@ -2942,6 +3336,26 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core", + "signature", + "spki", + "subtle", + "zeroize", +] + [[package]] name = "rustc-hash" version = "2.1.3" @@ -3050,8 +3464,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] -name = "same-file" -version = "1.0.6" +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" dependencies = [ @@ -3284,6 +3704,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "serde_with" version = "3.21.0" @@ -3347,6 +3779,17 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sha2" version = "0.10.9" @@ -3374,6 +3817,16 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core", +] + [[package]] name = "simd-adler32" version = "0.3.9" @@ -3413,6 +3866,9 @@ name = "smallvec" version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +dependencies = [ + "serde", +] [[package]] name = "socket2" @@ -3439,7 +3895,7 @@ dependencies = [ "objc2-foundation", "objc2-quartz-core", "raw-window-handle", - "redox_syscall", + "redox_syscall 0.5.18", "tracing", "wasm-bindgen", "web-sys", @@ -3472,6 +3928,215 @@ dependencies = [ "system-deps", ] +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sqlx" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" +dependencies = [ + "base64 0.22.1", + "bytes", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.15.5", + "hashlink", + "indexmap 2.14.0", + "log", + "memchr", + "once_cell", + "percent-encoding", + "rustls", + "serde", + "serde_json", + "sha2", + "smallvec", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tracing", + "url", + "webpki-roots 0.26.11", +] + +[[package]] +name = "sqlx-macros" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn 2.0.118", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" +dependencies = [ + "dotenvy", + "either", + "heck 0.5.0", + "hex", + "once_cell", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn 2.0.118", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" +dependencies = [ + "atoi", + "base64 0.22.1", + "bitflags 2.13.0", + "byteorder", + "bytes", + "crc", + "digest", + "dotenvy", + "either", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "generic-array", + "hex", + "hkdf", + "hmac", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "percent-encoding", + "rand", + "rsa", + "serde", + "sha1", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.18", + "tracing", + "whoami", +] + +[[package]] +name = "sqlx-postgres" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" +dependencies = [ + "atoi", + "base64 0.22.1", + "bitflags 2.13.0", + "byteorder", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac", + "home", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "rand", + "serde", + "serde_json", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.18", + "tracing", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" +dependencies = [ + "atoi", + "flume", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "serde_urlencoded", + "sqlx-core", + "thiserror 2.0.18", + "tracing", + "url", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -3502,6 +4167,17 @@ dependencies = [ "quote", ] +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + [[package]] name = "strsim" version = "0.11.1" @@ -4139,6 +4815,17 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -4323,6 +5010,7 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ + "log", "pin-project-lite", "tracing-attributes", "tracing-core", @@ -4440,12 +5128,33 @@ dependencies = [ "unic-common", ] +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + [[package]] name = "unicode-segmentation" version = "1.13.3" @@ -4501,6 +5210,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version-compare" version = "0.2.1" @@ -4567,6 +5282,12 @@ dependencies = [ "wit-bindgen", ] +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + [[package]] name = "wasm-bindgen" version = "0.2.126" @@ -4710,6 +5431,24 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.8", +] + +[[package]] +name = "webpki-roots" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webview2-com" version = "0.38.2" @@ -4746,6 +5485,16 @@ dependencies = [ "windows-core 0.61.2", ] +[[package]] +name = "whoami" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" +dependencies = [ + "libredox", + "wasite", +] + [[package]] name = "winapi" version = "0.3.9" @@ -4940,6 +5689,15 @@ dependencies = [ "windows-targets 0.42.2", ] +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -4991,6 +5749,21 @@ dependencies = [ "windows_x86_64_msvc 0.42.2", ] +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -5048,6 +5821,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -5066,6 +5845,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -5084,6 +5869,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -5114,6 +5905,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -5132,6 +5929,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -5150,6 +5953,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -5168,6 +5977,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -5389,12 +6204,15 @@ dependencies = [ name = "zebra-print-lab" version = "0.1.0" dependencies = [ + "calamine", + "chrono", "keyring", "libc", "nusb", "printers", "serde", "serde_json", + "sqlx", "tauri", "tauri-build", "tauri-plugin-dialog", @@ -5403,8 +6221,31 @@ dependencies = [ "tauri-plugin-process", "tauri-plugin-updater", "tauri-plugin-window-state", + "thiserror 1.0.69", "tokio", + "tokio-stream", "windows-sys 0.61.2", + "zip", +] + +[[package]] +name = "zerocopy" +version = "0.8.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", ] [[package]] @@ -5489,16 +6330,36 @@ checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1" dependencies = [ "arbitrary", "crc32fast", + "flate2", "indexmap 2.14.0", "memchr", + "zopfli", ] +[[package]] +name = "zlib-rs" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b142a20ec14a91d5bc708c1dc21b080c550113d8aa77afa29635673a65dd02c5" + [[package]] name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + [[package]] name = "zvariant" version = "5.13.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 476f6b60..d4f7e4a8 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -21,12 +21,30 @@ tauri = { version = "2.11.3", features = [] } serde_json = "1" serde = { version = "1", features = ["derive"] } tokio = { version = "1", features = ["net", "time", "io-util", "rt"] } +# Stream a query's rows (db.rs) instead of fetch_all so a huge result is bounded +# by a byte budget and errors instead of OOMing. Already transitive via sqlx; +# tokio-stream keeps us in the tokio ecosystem we already depend on directly. +tokio-stream = { version = "0.1", default-features = false } # OS-native raw printing: winspool RAW on Windows, libcups on macOS/Linux. # Sole transitive dep is libc. Linux CI needs libcups2-dev to link. printers = "2.3.0" # OS credential store for API keys: Windows Credential Manager, macOS # Keychain, Linux DBus Secret Service. Linux CI needs libdbus-1-dev. keyring = { version = "3", features = ["windows-native", "apple-native", "sync-secret-service"] } +# Read-only dataset connector (db.rs). Bundled SQLite, no system lib to link; +# rustls-ring for pg/mysql TLS (aws-lc would drag cmake/nasm into every build). +sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio", "sqlite", "postgres", "mysql", "tls-rustls-ring"] } +# Excel as a plain file reader (excel.rs): ZebraDesigner-parity without the +# ODBC driver-manager tax. dates maps serial datetimes via chrono. +calamine = { version = "0.30.1", features = ["dates"] } +# Already transitive via calamine; used directly in excel.rs to bound a +# workbook's DECLARED uncompressed size before calamine materializes it, so a +# decompression bomb can't defeat the compressed-only file-size cap. +zip = { version = "4", default-features = false, features = ["deflate"] } +chrono = { version = "0.4.45", default-features = false, features = ["std"] } +# Typed errors inside the db/excel connector (stringified only at the IPC edge). +# Already transitive via sqlx. +thiserror = "1" [target.'cfg(unix)'.dependencies] # Linux: O_NONBLOCK for the usblp read-back fd (AsyncFd needs a non-blocking diff --git a/src-tauri/THIRD-PARTY-LICENSES-RUST.md b/src-tauri/THIRD-PARTY-LICENSES-RUST.md index 93307ac1..df1e7704 100644 --- a/src-tauri/THIRD-PARTY-LICENSES-RUST.md +++ b/src-tauri/THIRD-PARTY-LICENSES-RUST.md @@ -8,14 +8,15 @@ drifts from the locked dependencies. ## Overview -- MIT License (387) +- MIT License (454) - Unicode License v3 (19) -- Apache License 2.0 (5) +- Apache License 2.0 (7) +- BSD 3-Clause "New" or "Revised" License (5) - Mozilla Public License 2.0 (5) -- BSD 3-Clause "New" or "Revised" License (4) - ISC License (3) +- zlib License (3) +- Community Data License Agreement Permissive 2.0 (2) - MIT No Attribution (1) -- zlib License (1) ## License texts @@ -246,6 +247,216 @@ the License, but only in their entirety and only with respect to the Combined Software. +```` + +### Apache License 2.0 + +Used by: + +- [zopfli 0.8.3](https://github.com/zopfli-rs/zopfli) +```` + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2011 Google Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + ```` ### Apache License 2.0 @@ -672,6 +883,7 @@ Apache License Used by: +- [ryu 1.0.23](https://github.com/dtolnay/ryu) - [sync_wrapper 1.0.2](https://github.com/Actyx/sync_wrapper) ```` Apache License @@ -830,6 +1042,112 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ```` +### BSD 3-Clause "New" or "Revised" License + +Used by: + +- [encoding_rs 0.8.35](https://github.com/hsivonen/encoding_rs) +```` +Copyright © WHATWG (Apple, Google, Mozilla, Microsoft). + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +```` + +### Community Data License Agreement Permissive 2.0 + +Used by: + +- [webpki-roots 0.26.11](https://github.com/rustls/webpki-roots) +- [webpki-roots 1.0.8](https://github.com/rustls/webpki-roots) +```` +# Community Data License Agreement - Permissive - Version 2.0 + +This is the Community Data License Agreement - Permissive, Version +2.0 (the "agreement"). Data Provider(s) and Data Recipient(s) agree +as follows: + +## 1. Provision of the Data + +1.1. A Data Recipient may use, modify, and share the Data made +available by Data Provider(s) under this agreement if that Data +Recipient follows the terms of this agreement. + +1.2. This agreement does not impose any restriction on a Data +Recipient's use, modification, or sharing of any portions of the +Data that are in the public domain or that may be used, modified, +or shared under any other legal exception or limitation. + +## 2. Conditions for Sharing Data + +2.1. A Data Recipient may share Data, with or without modifications, so +long as the Data Recipient makes available the text of this agreement +with the shared Data. + +## 3. No Restrictions on Results + +3.1. This agreement does not impose any restriction or obligations +with respect to the use, modification, or sharing of Results. + +## 4. No Warranty; Limitation of Liability + +4.1. All Data Recipients receive the Data subject to the following +terms: + +THE DATA IS PROVIDED ON AN "AS IS" BASIS, WITHOUT REPRESENTATIONS, +WARRANTIES OR CONDITIONS OF ANY KIND, EITHER EXPRESS OR IMPLIED +INCLUDING, WITHOUT LIMITATION, ANY WARRANTIES OR CONDITIONS OF TITLE, +NON-INFRINGEMENT, MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE. + +NO DATA PROVIDER SHALL HAVE ANY LIABILITY FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING +WITHOUT LIMITATION LOST PROFITS), HOWEVER CAUSED AND ON ANY THEORY OF +LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE DATA OR RESULTS, +EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. + +## 5. Definitions + +5.1. "Data" means the material received by a Data Recipient under +this agreement. + +5.2. "Data Provider" means any person who is the source of Data +provided under this agreement and in reliance on a Data Recipient's +agreement to its terms. + +5.3. "Data Recipient" means any person who receives Data directly +or indirectly from a Data Provider and agrees to the terms of this +agreement. + +5.4. "Results" means any outcome obtained by computational analysis +of Data, including for example machine learning models and models' +insights. + +```` + ### ISC License Used by: @@ -936,17 +1254,46 @@ Used by: Used by: -- [printers 2.3.0](https://github.com/talesluna/rust-printers) +- [dotenvy 0.15.7](https://github.com/allan2/dotenvy) ```` -(The MIT License) +# The MIT License (MIT) -Copyright (c) 2022 Tales Luna +Copyright (c) 2014 Santiago Lapresta and contributors -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -'Software'), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +```` + +### MIT License + +Used by: + +- [printers 2.3.0](https://github.com/talesluna/rust-printers) +```` +(The MIT License) + +Copyright (c) 2022 Tales Luna + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: @@ -967,6 +1314,8 @@ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. Used by: +- [md-5 0.10.6](https://github.com/RustCrypto/hashes) +- [sha1 0.10.6](https://github.com/RustCrypto/hashes) - [sha2 0.10.9](https://github.com/RustCrypto/hashes) ```` Copyright (c) 2006-2009 Graydon Hoare @@ -1031,6 +1380,40 @@ THE SOFTWARE. Used by: +- [lazy_static 1.5.0](https://github.com/rust-lang-nursery/lazy-static.rs) +```` +Copyright (c) 2010 The Rust Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +```` + +### MIT License + +Used by: + - [core-foundation-sys 0.8.7](https://github.com/servo/core-foundation-rs) - [core-foundation 0.10.1](https://github.com/servo/core-foundation-rs) - [core-graphics-types 0.2.0](https://github.com/servo/core-foundation-rs) @@ -1295,6 +1678,41 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +```` + +### MIT License + +Used by: + +- [base64ct 1.8.3](https://github.com/RustCrypto/formats) +```` +Copyright (c) 2014 Steve "Sc00bz" Thomas (steve at tobtu dot com) +Copyright (c) 2021-2025 The RustCrypto Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + ```` ### MIT License @@ -1305,6 +1723,10 @@ Used by: - [bitflags 2.13.0](https://github.com/bitflags/bitflags) - [glob 0.3.3](https://github.com/rust-lang/glob) - [log 0.4.33](https://github.com/rust-lang/log) +- [num-bigint-dig 0.8.6](https://github.com/dignifiedquire/num-bigint) +- [num-integer 0.1.46](https://github.com/rust-num/num-integer) +- [num-iter 0.1.45](https://github.com/rust-num/num-iter) +- [num-traits 0.2.19](https://github.com/rust-num/num-traits) - [regex-automata 0.4.14](https://github.com/rust-lang/regex) - [regex-syntax 0.8.11](https://github.com/rust-lang/regex) - [regex 1.12.4](https://github.com/rust-lang/regex) @@ -1440,6 +1862,34 @@ SOFTWARE. Used by: +- [libsqlite3-sys 0.30.1](https://github.com/rusqlite/rusqlite) +```` +Copyright (c) 2014-2021 The rusqlite developers + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +```` + +### MIT License + +Used by: + - [flate2 1.1.9](https://github.com/rust-lang/flate2-rs) ```` Copyright (c) 2014-2026 Alex Crichton @@ -1502,6 +1952,7 @@ THE SOFTWARE. Used by: +- [either 1.16.0](https://github.com/rayon-rs/either) - [serde_with 3.21.0](https://github.com/jonasbb/serde_with/) - [serde_with_macros 3.21.0](https://github.com/jonasbb/serde_with/) ```` @@ -1697,6 +2148,9 @@ Used by: - [heck 0.4.1](https://github.com/withoutboats/heck) - [heck 0.5.0](https://github.com/withoutboats/heck) +- [unicode-bidi 0.3.18](https://github.com/servo/unicode-bidi) +- [unicode-normalization 0.1.25](https://github.com/unicode-rs/unicode-normalization) +- [unicode-properties 0.1.4](https://github.com/unicode-rs/unicode-properties) - [unicode-segmentation 1.13.3](https://github.com/unicode-rs/unicode-segmentation) ```` Copyright (c) 2015 The Rust Project Developers @@ -1810,6 +2264,41 @@ SOFTWARE. Used by: +- [hkdf 0.12.4](https://github.com/RustCrypto/KDFs/) +```` +Copyright (c) 2015-2018 Vlad Filippov +Copyright (c) 2018-2021 RustCrypto Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +```` + +### MIT License + +Used by: + - [httparse 1.10.1](https://github.com/seanmonstar/httparse) ```` Copyright (c) 2015-2025 Sean McArthur @@ -1882,6 +2371,7 @@ DEALINGS IN THE SOFTWARE. Used by: - [hashbrown 0.12.3](https://github.com/rust-lang/hashbrown) +- [hashbrown 0.15.5](https://github.com/rust-lang/hashbrown) - [hashbrown 0.17.1](https://github.com/rust-lang/hashbrown) ```` Copyright (c) 2016 Amanieu d'Antras @@ -1916,6 +2406,40 @@ DEALINGS IN THE SOFTWARE. Used by: +- [serde_urlencoded 0.7.1](https://github.com/nox/serde_urlencoded) +```` +Copyright (c) 2016 Anthony Ramine + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +```` + +### MIT License + +Used by: + - [webkit2gtk-sys 2.0.2](https://github.com/tauri-apps/webkit2gtk-rs) ```` Copyright (c) 2016 Boucher, Antoni @@ -2246,6 +2770,7 @@ THE SOFTWARE. Used by: - [digest 0.10.7](https://github.com/RustCrypto/traits) +- [hmac 0.12.1](https://github.com/RustCrypto/MACs) ```` Copyright (c) 2017 Artyom Pavlov @@ -2340,6 +2865,41 @@ SOFTWARE. Used by: +- [vcpkg 0.2.15](https://github.com/mcgoo/vcpkg-rs) +```` +Copyright (c) 2017 Jim McGrath + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + + +```` + +### MIT License + +Used by: + - [enumflags2_derive 0.7.12](https://github.com/meithecatte/enumflags2) ```` Copyright (c) 2017 Maik Klein @@ -2499,6 +3059,34 @@ SOFTWARE. Used by: +- [stringprep 0.1.5](https://github.com/sfackler/rust-stringprep) +```` +Copyright (c) 2017 The rust-stringprep Developers + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +```` + +### MIT License + +Used by: + - [version-compare 0.2.1](https://gitlab.com/timvisee/version-compare) ```` Copyright (c) 2017 Tim Visée @@ -2874,17 +3462,51 @@ SOFTWARE. Used by: -- [try-lock 0.2.5](https://github.com/seanmonstar/try-lock) +- [signature 2.2.0](https://github.com/RustCrypto/traits/tree/master/signature) ```` -Copyright (c) 2018-2023 Sean McArthur -Copyright (c) 2016 Alex Crichton +Copyright (c) 2018-2023 RustCrypto Developers -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +```` + +### MIT License + +Used by: + +- [try-lock 0.2.5](https://github.com/seanmonstar/try-lock) +```` +Copyright (c) 2018-2023 Sean McArthur +Copyright (c) 2016 Alex Crichton + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. @@ -2904,10 +3526,284 @@ THE SOFTWARE. Used by: -- [getrandom 0.2.17](https://github.com/rust-random/getrandom) +- [getrandom 0.2.17](https://github.com/rust-random/getrandom) +```` +Copyright (c) 2018-2024 The rust-random Project Developers +Copyright (c) 2014 The Rust Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +```` + +### MIT License + +Used by: + +- [getrandom 0.3.4](https://github.com/rust-random/getrandom) +```` +Copyright (c) 2018-2025 The rust-random Project Developers +Copyright (c) 2014 The Rust Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +```` + +### MIT License + +Used by: + +- [zeroize 1.9.0](https://github.com/RustCrypto/utils) +```` +Copyright (c) 2018-2026 The RustCrypto Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +```` + +### MIT License + +Used by: + +- [getrandom 0.4.3](https://github.com/rust-random/getrandom) +```` +Copyright (c) 2018-2026 The rust-random Project Developers +Copyright (c) 2014 The Rust Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +```` + +### MIT License + +Used by: + +- [slab 0.4.12](https://github.com/tokio-rs/slab) +```` +Copyright (c) 2019 Carl Lerche + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +```` + +### MIT License + +Used by: + +- [cfg-expr 0.15.8](https://github.com/EmbarkStudios/cfg-expr) +```` +Copyright (c) 2019 Embark Studios + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +```` + +### MIT License + +Used by: + +- [futures-intrusive 0.5.0](https://github.com/Matthias247/futures-intrusive) +```` +Copyright (c) 2019 Matthias Einwag + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +```` + +### MIT License + +Used by: + +- [bumpalo 3.20.3](https://github.com/fitzgen/bumpalo) +```` +Copyright (c) 2019 Nick Fitzgerald + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +```` + +### MIT License + +Used by: + +- [mach2 0.5.0](https://github.com/JohnTitor/mach2) ```` -Copyright (c) 2018-2024 The rust-random Project Developers -Copyright (c) 2014 The Rust Project Developers +Copyright (c) 2019 Nick Fitzgerald, 2021 Yuki Okushi Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated @@ -2939,10 +3835,9 @@ DEALINGS IN THE SOFTWARE. Used by: -- [getrandom 0.3.4](https://github.com/rust-random/getrandom) +- [ppv-lite86 0.2.21](https://github.com/cryptocorrosion/cryptocorrosion) ```` -Copyright (c) 2018-2025 The rust-random Project Developers -Copyright (c) 2014 The Rust Project Developers +Copyright (c) 2019 The CryptoCorrosion Contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated @@ -2974,9 +3869,11 @@ DEALINGS IN THE SOFTWARE. Used by: -- [zeroize 1.9.0](https://github.com/RustCrypto/utils) +- [tracing-attributes 0.1.31](https://github.com/tokio-rs/tracing) +- [tracing-core 0.1.36](https://github.com/tokio-rs/tracing) +- [tracing 0.1.44](https://github.com/tokio-rs/tracing) ```` -Copyright (c) 2018-2026 The RustCrypto Project Developers +Copyright (c) 2019 Tokio Contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated @@ -3008,10 +3905,11 @@ DEALINGS IN THE SOFTWARE. Used by: -- [getrandom 0.4.3](https://github.com/rust-random/getrandom) +- [tower-layer 0.3.3](https://github.com/tower-rs/tower) +- [tower-service 0.3.3](https://github.com/tower-rs/tower) +- [tower 0.5.3](https://github.com/tower-rs/tower) ```` -Copyright (c) 2018-2026 The rust-random Project Developers -Copyright (c) 2014 The Rust Project Developers +Copyright (c) 2019 Tower Contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated @@ -3043,9 +3941,9 @@ DEALINGS IN THE SOFTWARE. Used by: -- [slab 0.4.12](https://github.com/tokio-rs/slab) +- [tower-http 0.6.11](https://github.com/tower-rs/tower-http) ```` -Copyright (c) 2019 Carl Lerche +Copyright (c) 2019-2021 Tower Contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated @@ -3077,9 +3975,9 @@ DEALINGS IN THE SOFTWARE. Used by: -- [cfg-expr 0.15.8](https://github.com/EmbarkStudios/cfg-expr) +- [http-body 1.0.1](https://github.com/hyperium/http-body) ```` -Copyright (c) 2019 Embark Studios +Copyright (c) 2019-2024 Sean McArthur & Hyper Contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated @@ -3111,9 +4009,9 @@ DEALINGS IN THE SOFTWARE. Used by: -- [mach2 0.5.0](https://github.com/JohnTitor/mach2) +- [http-body-util 0.1.3](https://github.com/hyperium/http-body) ```` -Copyright (c) 2019 Nick Fitzgerald, 2021 Yuki Okushi +Copyright (c) 2019-2025 Sean McArthur & Hyper Contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated @@ -3145,11 +4043,9 @@ DEALINGS IN THE SOFTWARE. Used by: -- [tracing-attributes 0.1.31](https://github.com/tokio-rs/tracing) -- [tracing-core 0.1.36](https://github.com/tokio-rs/tracing) -- [tracing 0.1.44](https://github.com/tokio-rs/tracing) +- [zeroize_derive 1.5.0](https://github.com/RustCrypto/utils) ```` -Copyright (c) 2019 Tokio Contributors +Copyright (c) 2019-2026 The RustCrypto Project Developers Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated @@ -3181,11 +4077,13 @@ DEALINGS IN THE SOFTWARE. Used by: -- [tower-layer 0.3.3](https://github.com/tower-rs/tower) -- [tower-service 0.3.3](https://github.com/tower-rs/tower) -- [tower 0.5.3](https://github.com/tower-rs/tower) +- [sqlx-core 0.8.6](https://github.com/launchbadge/sqlx) +- [sqlx-mysql 0.8.6](https://github.com/launchbadge/sqlx) +- [sqlx-postgres 0.8.6](https://github.com/launchbadge/sqlx) +- [sqlx-sqlite 0.8.6](https://github.com/launchbadge/sqlx) +- [sqlx 0.8.6](https://github.com/launchbadge/sqlx) ```` -Copyright (c) 2019 Tower Contributors +Copyright (c) 2020 LaunchBadge, LLC Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated @@ -3217,9 +4115,9 @@ DEALINGS IN THE SOFTWARE. Used by: -- [tower-http 0.6.11](https://github.com/tower-rs/tower-http) +- [const-oid 0.9.6](https://github.com/RustCrypto/formats/tree/master/const-oid) ```` -Copyright (c) 2019-2021 Tower Contributors +Copyright (c) 2020-2022 The RustCrypto Project Developers Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated @@ -3251,9 +4149,10 @@ DEALINGS IN THE SOFTWARE. Used by: -- [http-body 1.0.1](https://github.com/hyperium/http-body) +- [der 0.7.10](https://github.com/RustCrypto/formats/tree/master/der) +- [pkcs8 0.10.2](https://github.com/RustCrypto/formats/tree/master/pkcs8) ```` -Copyright (c) 2019-2024 Sean McArthur & Hyper Contributors +Copyright (c) 2020-2023 The RustCrypto Project Developers Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated @@ -3285,9 +4184,9 @@ DEALINGS IN THE SOFTWARE. Used by: -- [http-body-util 0.1.3](https://github.com/hyperium/http-body) +- [cpufeatures 0.2.17](https://github.com/RustCrypto/utils) ```` -Copyright (c) 2019-2025 Sean McArthur & Hyper Contributors +Copyright (c) 2020-2025 The RustCrypto Project Developers Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated @@ -3319,9 +4218,9 @@ DEALINGS IN THE SOFTWARE. Used by: -- [zeroize_derive 1.5.0](https://github.com/RustCrypto/utils) +- [crypto-common 0.1.7](https://github.com/RustCrypto/traits) ```` -Copyright (c) 2019-2026 The RustCrypto Project Developers +Copyright (c) 2021 RustCrypto Developers Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated @@ -3353,9 +4252,9 @@ DEALINGS IN THE SOFTWARE. Used by: -- [cpufeatures 0.2.17](https://github.com/RustCrypto/utils) +- [pem-rfc7468 0.7.0](https://github.com/RustCrypto/formats/tree/master/pem-rfc7468) ```` -Copyright (c) 2020-2025 The RustCrypto Project Developers +Copyright (c) 2021 The RustCrypto Project Developers Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated @@ -3387,9 +4286,10 @@ DEALINGS IN THE SOFTWARE. Used by: -- [crypto-common 0.1.7](https://github.com/RustCrypto/traits) +- [pkcs1 0.7.5](https://github.com/RustCrypto/formats/tree/master/pkcs1) +- [spki 0.7.3](https://github.com/RustCrypto/formats/tree/master/spki) ```` -Copyright (c) 2021 RustCrypto Developers +Copyright (c) 2021-2023 The RustCrypto Project Developers Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated @@ -3674,6 +4574,40 @@ DEALINGS IN THE SOFTWARE. Used by: +- [debug_unsafe 0.1.4](https://github.com/RoDmitry/debug_unsafe) +```` +Copyright (c) 2025 Dmitry Rodionov + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +```` + +### MIT License + +Used by: + - [serde_spanned 0.6.9](https://github.com/toml-rs/toml) - [serde_spanned 1.1.1](https://github.com/toml-rs/toml) - [toml 0.8.2](https://github.com/toml-rs/toml) @@ -3905,6 +4839,43 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI Used by: +- [rand 0.8.6](https://github.com/rust-random/rand) +- [rand_chacha 0.3.1](https://github.com/rust-random/rand) +- [rand_core 0.6.4](https://github.com/rust-random/rand) +```` +Copyright 2018 Developers of the Rand project +Copyright (c) 2014 The Rust Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +```` + +### MIT License + +Used by: + - [softbuffer 0.4.8](https://github.com/rust-windowing/softbuffer) ```` Copyright 2022 Kirill Chibisov @@ -3921,46 +4892,173 @@ copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL -THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL +THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + +```` + +### MIT License + +Used by: + +- [zerocopy 0.8.54](https://github.com/google/zerocopy) +```` +Copyright 2023 The Fuchsia Authors + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + + +```` + +### MIT License + +Used by: + +- [codepage 0.1.2](https://github.com/hsivonen/codepage) +- [encoding_rs 0.8.35](https://github.com/hsivonen/encoding_rs) +- [utf8_iter 1.0.4](https://github.com/hsivonen/utf8_iter) +```` +Copyright Mozilla Foundation + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +```` + +### MIT License + +Used by: + +- [field-offset 0.3.6](https://github.com/Diggsey/rust-field-offset) +```` +MIT License + +Copyright (c) 2016-2021 Diggory Blake, and other contributors. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +```` + +### MIT License + +Used by: + +- [atoi 2.0.0](https://github.com/pacman82/atoi-rs) +```` +MIT License + +Copyright (c) 2017 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - ```` ### MIT License Used by: -- [utf8_iter 1.0.4](https://github.com/hsivonen/utf8_iter) +- [precomputed-hash 0.1.1](https://github.com/emilio/precomputed-hash) ```` -Copyright Mozilla Foundation +MIT License -Permission is hereby granted, free of charge, to any -person obtaining a copy of this software and associated -documentation files (the "Software"), to deal in the -Software without restriction, including without -limitation the rights to use, copy, modify, merge, -publish, distribute, sublicense, and/or sell copies of -the Software, and to permit persons to whom the Software -is furnished to do so, subject to the following -conditions: +Copyright (c) 2017 Emilio Cobos Álvarez -The above copyright notice and this permission notice -shall be included in all copies or substantial portions -of the Software. +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF -ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED -TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A -PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT -SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION -OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR -IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -DEALINGS IN THE SOFTWARE. +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. ```` @@ -3968,11 +5066,11 @@ DEALINGS IN THE SOFTWARE. Used by: -- [field-offset 0.3.6](https://github.com/Diggsey/rust-field-offset) +- [json-patch 3.0.1](https://github.com/idubrov/json-patch) ```` MIT License -Copyright (c) 2016-2021 Diggory Blake, and other contributors. +Copyright (c) 2017 Ivan Dubrov Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -3998,11 +5096,11 @@ SOFTWARE. Used by: -- [precomputed-hash 0.1.1](https://github.com/emilio/precomputed-hash) +- [cfb 0.7.3](https://github.com/mdsteele/rust-cfb) ```` MIT License -Copyright (c) 2017 Emilio Cobos Álvarez +Copyright (c) 2017 Matthew D. Steele Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -4028,11 +5126,13 @@ SOFTWARE. Used by: -- [json-patch 3.0.1](https://github.com/idubrov/json-patch) +- [darling 0.23.0](https://github.com/TedDriggs/darling) +- [darling_core 0.23.0](https://github.com/TedDriggs/darling) +- [darling_macro 0.23.0](https://github.com/TedDriggs/darling) ```` MIT License -Copyright (c) 2017 Ivan Dubrov +Copyright (c) 2017 Ted Driggs Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -4058,11 +5158,11 @@ SOFTWARE. Used by: -- [cfb 0.7.3](https://github.com/mdsteele/rust-cfb) +- [crc 3.4.0](https://github.com/mrhooray/crc-rs.git) ```` MIT License -Copyright (c) 2017 Matthew D. Steele +Copyright (c) 2017 crc-rs Developers Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -4088,13 +5188,11 @@ SOFTWARE. Used by: -- [darling 0.23.0](https://github.com/TedDriggs/darling) -- [darling_core 0.23.0](https://github.com/TedDriggs/darling) -- [darling_macro 0.23.0](https://github.com/TedDriggs/darling) +- [ico 0.5.0](https://github.com/mdsteele/rust-ico) ```` MIT License -Copyright (c) 2017 Ted Driggs +Copyright (c) 2018 Matthew D. Steele Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -4120,11 +5218,11 @@ SOFTWARE. Used by: -- [ico 0.5.0](https://github.com/mdsteele/rust-ico) +- [crc32fast 1.5.0](https://github.com/srijs/rust-crc32fast) ```` MIT License -Copyright (c) 2018 Matthew D. Steele +Copyright (c) 2018 Sam Rijs, Alex Crichton and contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -4150,11 +5248,11 @@ SOFTWARE. Used by: -- [crc32fast 1.5.0](https://github.com/srijs/rust-crc32fast) +- [crc-catalog 2.5.0](https://github.com/akhilles/crc-catalog.git) ```` MIT License -Copyright (c) 2018 Sam Rijs, Alex Crichton and contributors +Copyright (c) 2019 Akhil Velagapudi Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -4332,6 +5430,36 @@ SOFTWARE. Used by: +- [tinyvec_macros 0.1.1](https://github.com/Soveu/tinyvec_macros) +```` +MIT License + +Copyright (c) 2020 Soveu + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +```` + +### MIT License + +Used by: + - [window-vibrancy 0.6.0](https://github.com/tauri-apps/tauri-plugin-vibrancy) ```` MIT License @@ -4640,13 +5768,16 @@ SOFTWARE. Used by: - [zebra-print-lab 0.1.0](https://github.com/u8array/ZebraPrintLab) +- [atoi_simd 0.16.1](https://github.com/RoDmitry/atoi_simd) - [block2 0.6.2](https://github.com/madsmtm/objc2) - [brotli-decompressor 5.0.3](https://github.com/dropbox/rust-brotli-decompressor) - [cargo_toml 0.22.3](https://gitlab.com/lib.rs/cargo_toml) +- [chrono 0.4.45](https://github.com/chronotope/chrono) - [dispatch2 0.3.1](https://github.com/madsmtm/objc2) - [dlopen2 0.8.2](https://github.com/OpenByteDev/dlopen2) - [dlopen2_derive 0.4.3](https://github.com/OpenByteDev/dlopen2) - [dpi 0.1.2](https://github.com/rust-windowing/winit) +- [libm 0.2.16](https://github.com/rust-lang/compiler-builtins) - [minisign-verify 0.2.5](https://github.com/jedisct1/rust-minisign-verify) - [objc2-app-kit 0.3.2](https://github.com/madsmtm/objc2) - [objc2-core-foundation 0.3.2](https://github.com/madsmtm/objc2) @@ -4681,6 +5812,7 @@ Used by: - [webview2-com-macros 0.8.1](https://github.com/wravery/webview2-rs) - [webview2-com-sys 0.38.2](https://github.com/wravery/webview2-rs) - [webview2-com 0.38.2](https://github.com/wravery/webview2-rs) +- [whoami 1.6.1](https://github.com/ardaku/whoami) - [windows-collections 0.2.0](https://github.com/microsoft/windows-rs) - [windows-core 0.61.2](https://github.com/microsoft/windows-rs) - [windows-future 0.2.1](https://github.com/microsoft/windows-rs) @@ -4691,16 +5823,20 @@ Used by: - [windows-numerics 0.2.0](https://github.com/microsoft/windows-rs) - [windows-result 0.3.4](https://github.com/microsoft/windows-rs) - [windows-strings 0.4.2](https://github.com/microsoft/windows-rs) +- [windows-sys 0.48.0](https://github.com/microsoft/windows-rs) - [windows-sys 0.59.0](https://github.com/microsoft/windows-rs) - [windows-sys 0.60.2](https://github.com/microsoft/windows-rs) - [windows-sys 0.61.2](https://github.com/microsoft/windows-rs) +- [windows-targets 0.48.5](https://github.com/microsoft/windows-rs) - [windows-targets 0.52.6](https://github.com/microsoft/windows-rs) - [windows-targets 0.53.5](https://github.com/microsoft/windows-rs) - [windows-threading 0.1.0](https://github.com/microsoft/windows-rs) - [windows-version 0.1.7](https://github.com/microsoft/windows-rs) - [windows 0.61.3](https://github.com/microsoft/windows-rs) +- [windows_x86_64_gnu 0.48.5](https://github.com/microsoft/windows-rs) - [windows_x86_64_gnu 0.52.6](https://github.com/microsoft/windows-rs) - [windows_x86_64_gnu 0.53.1](https://github.com/microsoft/windows-rs) +- [windows_x86_64_msvc 0.48.5](https://github.com/microsoft/windows-rs) - [windows_x86_64_msvc 0.52.6](https://github.com/microsoft/windows-rs) - [windows_x86_64_msvc 0.53.1](https://github.com/microsoft/windows-rs) ```` @@ -4729,6 +5865,7 @@ USE OR OTHER DEALINGS IN THE SOFTWARE. Used by: +- [tokio-stream 0.1.18](https://github.com/tokio-rs/tokio) - [tokio-util 0.7.18](https://github.com/tokio-rs/tokio) - [tokio 1.52.3](https://github.com/tokio-rs/tokio) ```` @@ -4887,6 +6024,8 @@ SOFTWARE. Used by: - [async-recursion 1.1.1](https://github.com/dcchut/async-recursion) +- [flume 0.11.1](https://github.com/zesterer/flume) +- [rsa 0.9.10](https://github.com/RustCrypto/RSA) - [rustc-hash 2.1.3](https://github.com/rust-lang/rustc-hash) ```` Permission is hereby granted, free of charge, to any @@ -4939,10 +6078,13 @@ Used by: - [dyn-clone 1.0.20](https://github.com/dtolnay/dyn-clone) - [endi 1.1.1](https://github.com/zeenix/endi) - [erased-serde 0.4.10](https://github.com/dtolnay/erased-serde) +- [etcetera 0.8.0](https://github.com/lunacookies/etcetera) - [event-listener-strategy 0.5.4](https://github.com/smol-rs/event-listener-strategy) - [event-listener 5.4.1](https://github.com/smol-rs/event-listener) +- [fast-float2 0.2.3](https://github.com/Alexhuszagh/fast-float-rust) - [fastrand 2.4.1](https://github.com/smol-rs/fastrand) - [futures-lite 2.6.1](https://github.com/smol-rs/futures-lite) +- [home 0.5.12](https://github.com/rust-lang/cargo) - [itoa 1.0.18](https://github.com/dtolnay/itoa) - [linux-raw-sys 0.12.1](https://github.com/sunfishcode/linux-raw-sys) - [nusb 0.2.4](https://github.com/kevinmehall/nusb) @@ -5011,6 +6153,38 @@ DEALINGS IN THE SOFTWARE. Used by: +- [allocator-api2 0.2.21](https://github.com/zakarumych/allocator-api2) +```` +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +```` + +### MIT License + +Used by: + - [winnow 0.5.40](https://github.com/winnow-rs/winnow) - [winnow 0.7.15](https://github.com/winnow-rs/winnow) - [winnow 1.0.3](https://github.com/winnow-rs/winnow) @@ -5102,6 +6276,20 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI Used by: +- [tinyvec 1.11.0](https://github.com/Lokathor/tinyvec) +```` +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +```` + +### MIT License + +Used by: + - [javascriptcore-rs-sys 1.1.1](https://github.com/tauri-apps/javascriptcore-rs) - [soup3-sys 0.5.0](https://gitlab.gnome.org/World/Rust/soup3-rs) - [soup3 0.5.0](https://gitlab.gnome.org/World/Rust/soup3-rs) @@ -5167,6 +6355,35 @@ SOFTWARE. Used by: +- [spin 0.9.9](https://github.com/mvdnes/spin-rs.git) +```` +The MIT License (MIT) + +Copyright (c) 2014 Mathijs van de Nes + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +```` + +### MIT License + +Used by: + - [zip 4.6.1](https://github.com/zip-rs/zip2.git) ```` The MIT License (MIT) @@ -5449,6 +6666,38 @@ SOFTWARE. Used by: +- [calamine 0.30.1](https://github.com/tafia/calamine) +```` +The MIT License (MIT) + +Copyright (c) 2016 Johann Tuffe + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +```` + +### MIT License + +Used by: + - [same-file 1.0.6](https://github.com/BurntSushi/same-file) - [winapi-util 0.1.11](https://github.com/BurntSushi/winapi-util) ```` @@ -5511,6 +6760,7 @@ SOFTWARE. Used by: - [crossbeam-channel 0.5.16](https://github.com/crossbeam-rs/crossbeam) +- [crossbeam-queue 0.3.13](https://github.com/crossbeam-rs/crossbeam) - [crossbeam-utils 0.8.22](https://github.com/crossbeam-rs/crossbeam) ```` The MIT License (MIT) @@ -5737,6 +6987,7 @@ SOFTWARE. Used by: +- [quick-xml 0.37.5](https://github.com/tafia/quick-xml) - [quick-xml 0.41.0](https://github.com/tafia/quick-xml) ```` The MIT License (MIT) @@ -5765,6 +7016,40 @@ THE SOFTWARE. ```` +### MIT License + +Used by: + +- [hashlink 0.10.0](https://github.com/kyren/hashlink) +```` +This work is derived in part from the `linked-hash-map` crate, Copyright (c) +2015 The Rust Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +```` + ### MIT No Attribution Used by: @@ -7062,6 +8347,35 @@ ICU 1.8.1 to ICU 57.1 © 1995-2016 International Business Machines Corporation a Used by: +- [zlib-rs 0.6.6](https://github.com/trifectatechfoundation/zlib-rs) +```` +(C) 2024 Trifecta Tech Foundation + +This software is provided 'as-is', without any express or implied +warranty. In no event will the authors be held liable for any damages +arising from the use of this software. + +Permission is granted to anyone to use this software for any purpose, +including commercial applications, and to alter it and redistribute it +freely, subject to the following restrictions: + +1. The origin of this software must not be misrepresented; you must not + claim that you wrote the original software. If you use this software + in a product, an acknowledgment in the product documentation would be + appreciated but is not required. + +2. Altered source versions must be plainly marked as such, and must not be + misrepresented as being the original software. + +3. This notice may not be removed or altered from any source distribution. + +```` + +### zlib License + +Used by: + +- [foldhash 0.1.5](https://github.com/orlp/foldhash) - [foldhash 0.2.0](https://github.com/orlp/foldhash) ```` Copyright (c) 2024 Orson Peters diff --git a/src-tauri/deny.toml b/src-tauri/deny.toml index 3e1b73ca..a85160c5 100644 --- a/src-tauri/deny.toml +++ b/src-tauri/deny.toml @@ -31,4 +31,5 @@ exceptions = [ { crate = "target-lexicon", allow = ["Apache-2.0 WITH LLVM-exception"] }, # Permissive DATA license (Mozilla CA certificates for rustls). { crate = "webpki-root-certs", allow = ["CDLA-Permissive-2.0"] }, + { crate = "webpki-roots", allow = ["CDLA-Permissive-2.0"] }, ] diff --git a/src-tauri/src/credentials.rs b/src-tauri/src/credentials.rs index 208aa66c..6d9ac7c6 100644 --- a/src-tauri/src/credentials.rs +++ b/src-tauri/src/credentials.rs @@ -10,37 +10,117 @@ use crate::transport::blocking; /// the account under it. const SERVICE: &str = "ZPLab"; -fn entry(name: &str) -> Result { - Entry::new(SERVICE, name).map_err(|e| e.to_string()) +/// Typed credential error; the db connector consumes it via `#[from]`, the IPC +/// commands stringify it at the edge. +#[derive(Debug, thiserror::Error)] +pub(crate) enum CredError { + #[error(transparent)] + Keyring(#[from] keyring::Error), + #[error("credential is not readable over IPC: {0}")] + NotReadable(String), + #[error("credential is not writable over IPC: {0}")] + NotWritable(String), +} + +fn entry(name: &str) -> Result { + Ok(Entry::new(SERVICE, name)?) +} + +/// Rust-internal read (db connector), unlike the IPC `credential_get` +/// command. Blocking; call via `transport::blocking`. +pub(crate) fn read_password(name: &str) -> Result, CredError> { + match entry(name)?.get_password() { + Ok(v) => Ok(Some(v)), + Err(keyring::Error::NoEntry) => Ok(None), + Err(e) => Err(e.into()), + } +} + +/// Rust-internal write (db connector stores the endpoint-bound blob). Blocking. +pub(crate) fn write_password(name: &str, value: &str) -> Result<(), CredError> { + entry(name)?.set_password(value)?; + Ok(()) +} + +/// db-profile passwords flow keychain -> Rust connector only (db.rs +/// `password_cred`): the webview may delete them but never read or write them +/// over the generic IPC (writes go through the endpoint-binding `db_set_password`). +pub(crate) const RUST_ONLY_PREFIX: &str = "db-profile-"; + +/// Windows Credential Manager matches target names case-insensitively, so the +/// guard must too or `DB-PROFILE-x` slips past yet resolves the same secret. +/// Allocation-free ASCII prefix compare. +pub(crate) fn is_rust_only(name: &str) -> bool { + name + .as_bytes() + .get(..RUST_ONLY_PREFIX.len()) + .is_some_and(|p| p.eq_ignore_ascii_case(RUST_ONLY_PREFIX.as_bytes())) } #[tauri::command] pub async fn credential_get(name: String) -> Result, String> { + if is_rust_only(&name) { + return Err(CredError::NotReadable(name).to_string()); + } // keyring is blocking (DBus/OS calls); keep it off the async runtime. - blocking(move || match entry(&name)?.get_password() { - Ok(v) => Ok(Some(v)), - Err(keyring::Error::NoEntry) => Ok(None), - Err(e) => Err(e.to_string()), - }) - .await? + blocking(move || read_password(&name)) + .await? + .map_err(|e| e.to_string()) } #[tauri::command] pub async fn credential_set(name: String, value: String) -> Result<(), String> { - blocking(move || { - entry(&name)? - .set_password(&value) - .map_err(|e| e.to_string()) - }) - .await? + if is_rust_only(&name) { + return Err(CredError::NotWritable(name).to_string()); + } + blocking(move || write_password(&name, &value)) + .await? + .map_err(|e| e.to_string()) } #[tauri::command] pub async fn credential_delete(name: String) -> Result<(), String> { - blocking(move || match entry(&name)?.delete_credential() { - // Deleting a missing entry is the caller's desired end state, not an error. - Ok(()) | Err(keyring::Error::NoEntry) => Ok(()), - Err(e) => Err(e.to_string()), + blocking(move || -> Result<(), CredError> { + match entry(&name)?.delete_credential() { + // Deleting a missing entry is the caller's desired end state, not an error. + Ok(()) | Err(keyring::Error::NoEntry) => Ok(()), + Err(e) => Err(e.into()), + } }) .await? + .map_err(|e| e.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn db_profile_credentials_are_not_readable_over_ipc() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + let err = rt + .block_on(credential_get("db-profile-p1".into())) + .unwrap_err(); + assert!(err.contains("not readable")); + // Case-variant must not slip past the guard (Windows CredMan is case-insensitive). + let err = rt + .block_on(credential_get("DB-PROFILE-p1".into())) + .unwrap_err(); + assert!(err.contains("not readable")); + } + + #[test] + fn db_profile_credentials_are_not_writable_over_ipc() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + let err = rt + .block_on(credential_set("db-profile-p1".into(), "x".into())) + .unwrap_err(); + assert!(err.contains("not writable")); + } } diff --git a/src-tauri/src/dataset.rs b/src-tauri/src/dataset.rs new file mode 100644 index 00000000..c944d88f --- /dev/null +++ b/src-tauri/src/dataset.rs @@ -0,0 +1,15 @@ +//! The neutral tabular result both data sources (db, excel) produce, so neither +//! source owns the other's contract. + +use serde::Serialize; + +/// Rows beyond this are cut off and flagged; batch printing tens of thousands +/// of labels in one go is out of scope for the editor. +pub(crate) const ROW_CAP: usize = 10_000; + +#[derive(Serialize, Debug)] +pub struct Rows { + pub headers: Vec, + pub rows: Vec>, + pub truncated: bool, +} diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs new file mode 100644 index 00000000..1e849c8b --- /dev/null +++ b/src-tauri/src/db.rs @@ -0,0 +1,890 @@ +//! Read-only database connector. The webview sends only a connection spec plus +//! a table name and gets stringified rows back: SQL is composed here against +//! validated identifiers and passwords resolve from the OS keychain, so neither +//! crosses the IPC boundary. TLS is per-profile (`prefer` default); discovery +//! binds the default schema, so schema-qualified links are a deferred follow-up. + +use std::time::Duration; + +use serde::Deserialize; +use sqlx::mysql::{MySqlConnectOptions, MySqlConnection, MySqlSslMode}; +use sqlx::postgres::{PgConnectOptions, PgConnection, PgSslMode}; +use sqlx::sqlite::{SqliteConnectOptions, SqliteConnection}; +use sqlx::{ConnectOptions, Connection, Executor}; + +use crate::credentials; +use crate::dataset::{Rows, ROW_CAP}; +use crate::transport::blocking; + +/// Byte budget for a fetched result. ROW_CAP bounds the row count but not cell +/// size, and streaming rows one at a time still needs a ceiling or a table of +/// huge TEXT/BLOB cells could exhaust RAM. Generous vs any real label dataset. +const MAX_FETCH_BYTES: usize = 128 * 1024 * 1024; + +/// Typed connector error. Internals propagate this via `?`; only the +/// `#[tauri::command]` entry points stringify it at the IPC edge. +#[derive(Debug, thiserror::Error)] +enum DbError { + #[error(transparent)] + Sqlx(#[from] sqlx::Error), + #[error(transparent)] + Cred(#[from] credentials::CredError), + #[error("connection timed out")] + ConnectTimeout, + #[error("query timed out")] + QueryTimeout, + #[error( + "result too large (over {} bytes); narrow the selection", + MAX_FETCH_BYTES + )] + OverBudget, + #[error("unknown table: {0}")] + UnknownTable(String), + #[error("stored password no longer matches the connection settings; re-enter it")] + PasswordMismatch, + #[error("stored credential is malformed; re-enter the password")] + PasswordMalformed, + #[error("sqlite has no password")] + SqliteNoPassword, + /// The transport::blocking worker thread panicked (stringified JoinError). + /// The one explicit stringly bridge; converted only at that call site. + #[error("{0}")] + Join(String), +} + +/// Single source of the over-budget check, so the streaming query and its test +/// agree on the threshold. +fn check_fetch_budget(bytes: usize) -> Result<(), DbError> { + if bytes > MAX_FETCH_BYTES { + return Err(DbError::OverBudget); + } + Ok(()) +} + +const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); +/// Generous: a capped 10k-row fetch over a slow link is legitimate, an +/// indefinitely hung SELECT is not. +const QUERY_TIMEOUT: Duration = Duration::from_secs(30); + +/// Bound a fallible future by `dur`, yielding `on_timeout` if it elapses. +async fn with_timeout( + dur: Duration, + on_timeout: DbError, + work: impl std::future::Future>, +) -> Result { + tokio::time::timeout(dur, work) + .await + .map_err(|_| on_timeout)? +} + +/// TLS level, normalised across drivers. Field defaults to Prefer so a client +/// (or an older wire payload) that omits it keeps the opportunistic behaviour. +#[derive(Deserialize, Clone, Copy, Default)] +#[serde(rename_all = "kebab-case")] +pub enum SslMode { + Disable, + #[default] + Prefer, + Require, + VerifyFull, +} + +impl SslMode { + fn tag(self) -> &'static str { + match self { + SslMode::Disable => "disable", + SslMode::Prefer => "prefer", + SslMode::Require => "require", + SslMode::VerifyFull => "verify-full", + } + } +} + +// Driver-enum mappings colocated with SslMode so a new variant is a compile +// error in all three spots (here, the tag above, connect) at once. +impl From for PgSslMode { + fn from(m: SslMode) -> Self { + match m { + SslMode::Disable => PgSslMode::Disable, + SslMode::Prefer => PgSslMode::Prefer, + SslMode::Require => PgSslMode::Require, + SslMode::VerifyFull => PgSslMode::VerifyFull, + } + } +} + +impl From for MySqlSslMode { + fn from(m: SslMode) -> Self { + match m { + SslMode::Disable => MySqlSslMode::Disabled, + SslMode::Prefer => MySqlSslMode::Preferred, + SslMode::Require => MySqlSslMode::Required, + SslMode::VerifyFull => MySqlSslMode::VerifyIdentity, + } + } +} + +const PG_DEFAULT_PORT: u16 = 5432; +const MYSQL_DEFAULT_PORT: u16 = 3306; + +#[derive(Deserialize, Clone)] +#[serde(tag = "driver", rename_all = "lowercase")] +pub enum DbSpec { + Sqlite { + path: String, + }, + #[serde(rename_all = "camelCase")] + Postgres { + host: String, + port: Option, + database: String, + user: String, + profile_id: String, + #[serde(default)] + ssl_mode: SslMode, + }, + #[serde(rename_all = "camelCase")] + Mysql { + host: String, + port: Option, + database: String, + user: String, + profile_id: String, + #[serde(default)] + ssl_mode: SslMode, + }, +} + +enum DbConn { + Sqlite(SqliteConnection), + Postgres(PgConnection), + Mysql(MySqlConnection), +} + +/// Identifier-quoting and text-cast flavour. MySQL differs; the rest are ANSI. +#[derive(Clone, Copy)] +enum Dialect { + Ansi, + MySql, +} + +impl DbConn { + fn dialect(&self) -> Dialect { + match self { + DbConn::Mysql(_) => Dialect::MySql, + _ => Dialect::Ansi, + } + } +} + +/// Keychain account holding a profile's password. The stored value is +/// `endpoint\npassword` (see `db_set_password`); the webview may set/delete it +/// but never read it (IPC guard in credentials.rs). +fn password_cred(profile_id: &str) -> String { + format!("{}{profile_id}", credentials::RUST_ONLY_PREFIX) +} + +/// Full connection identity the stored password is bound to (host, resolved +/// port, ssl_mode, user, database); a fetch differing on any of these can't +/// reuse the secret, so a compromised webview can't replay or downgrade it. +fn endpoint_id(host: &str, port: u16, ssl: SslMode, user: &str, database: &str) -> String { + format!("{host}|{port}|{}|{user}|{database}", ssl.tag()) +} + +/// The (profile_id, endpoint_id) a network spec's password is keyed by, or None +/// for sqlite. Single definition, so the store path (db_set_password) and the +/// resolve path (connect) can't drift and silently break the binding. +fn password_binding(spec: &DbSpec) -> Option<(&str, String)> { + match spec { + DbSpec::Sqlite { .. } => None, + DbSpec::Postgres { + profile_id, + host, + port, + ssl_mode, + user, + database, + } => Some(( + profile_id, + endpoint_id( + host, + port.unwrap_or(PG_DEFAULT_PORT), + *ssl_mode, + user, + database, + ), + )), + DbSpec::Mysql { + profile_id, + host, + port, + ssl_mode, + user, + database, + } => Some(( + profile_id, + endpoint_id( + host, + port.unwrap_or(MYSQL_DEFAULT_PORT), + *ssl_mode, + user, + database, + ), + )), + } +} + +/// Extract the password from a stored `endpoint\npassword` blob only when the +/// endpoint matches; a mismatch or malformed blob refuses rather than leak. +fn password_for_endpoint(blob: Option<&str>, endpoint: &str) -> Result, DbError> { + match blob { + None => Ok(None), + Some(b) => match b.split_once('\n') { + Some((stored, pw)) if stored == endpoint => Ok(Some(pw.to_string())), + // host/port/ssl/user/database changed since the password was saved (the + // password is endpoint-bound), so it can't be released; re-enter it. + Some(_) => Err(DbError::PasswordMismatch), + None => Err(DbError::PasswordMalformed), + }, + } +} + +async fn keychain_password(profile_id: &str, endpoint: &str) -> Result, DbError> { + let name = password_cred(profile_id); + let blob = blocking(move || credentials::read_password(&name)) + .await + .map_err(DbError::Join)??; + password_for_endpoint(blob.as_deref(), endpoint) +} + +async fn connect(spec: &DbSpec) -> Result { + // Resolved BEFORE the network timeout: a keychain read can block on an OS + // permission prompt, which must not eat the connect budget. + let password = match password_binding(spec) { + None => None, + Some((profile_id, endpoint)) => keychain_password(profile_id, &endpoint).await?, + }; + let connect = async { + match spec { + DbSpec::Sqlite { path } => Ok(DbConn::Sqlite( + SqliteConnectOptions::new() + .filename(path) + .read_only(true) + .connect() + .await?, + )), + DbSpec::Postgres { + host, + port, + database, + user, + ssl_mode, + .. + } => { + let mut opts = PgConnectOptions::new() + .host(host) + .port(port.unwrap_or(PG_DEFAULT_PORT)) + .database(database) + .username(user) + .ssl_mode((*ssl_mode).into()) + // Server-side session default: even a hand-crafted statement + // reaching this connection cannot write. + .options([("default_transaction_read_only", "on")]); + if let Some(pw) = &password { + opts = opts.password(pw); + } + Ok(DbConn::Postgres(opts.connect().await?)) + } + DbSpec::Mysql { + host, + port, + database, + user, + ssl_mode, + .. + } => { + let mut opts = MySqlConnectOptions::new() + .host(host) + .port(port.unwrap_or(MYSQL_DEFAULT_PORT)) + .database(database) + .username(user) + .ssl_mode((*ssl_mode).into()); + if let Some(pw) = &password { + opts = opts.password(pw); + } + let mut conn = opts.connect().await?; + // No connect-option equivalent of postgres' default_transaction_ + // read_only here; the standard SET syntax covers MySQL and MariaDB. + conn.execute("SET SESSION TRANSACTION READ ONLY").await?; + Ok(DbConn::Mysql(conn)) + } + } + }; + with_timeout(CONNECT_TIMEOUT, DbError::ConnectTimeout, connect).await +} + +async fn close(conn: DbConn) { + let _ = match conn { + DbConn::Sqlite(c) => c.close().await, + DbConn::Postgres(c) => c.close().await, + DbConn::Mysql(c) => c.close().await, + }; +} + +fn quote_ident(dialect: Dialect, name: &str) -> String { + match dialect { + Dialect::MySql => format!("`{}`", name.replace('`', "``")), + Dialect::Ansi => format!("\"{}\"", name.replace('"', "\"\"")), + } +} + +/// CAST in SQL gives uniform text across value types and drivers; COALESCE +/// turns NULL into the empty string the dataset model expects. +fn text_cell(dialect: Dialect, quoted_ident: &str) -> String { + match dialect { + Dialect::MySql => format!("COALESCE(CAST({quoted_ident} AS CHAR), '')"), + Dialect::Ansi => format!("COALESCE(CAST({quoted_ident} AS TEXT), '')"), + } +} + +/// Decode one already-text-cast column. A BLOB survives CAST with its raw +/// bytes, so a non-UTF-8 cell would fail String decode and sink the whole +/// fetch; fall back to a lossy byte decode so one bad cell can't. +fn text_cell_at<'r, R>(row: &'r R, i: usize) -> Result +where + R: sqlx::Row, + String: sqlx::Decode<'r, R::Database> + sqlx::Type, + Vec: sqlx::Decode<'r, R::Database> + sqlx::Type, + usize: sqlx::ColumnIndex, +{ + match row.try_get::(i) { + Ok(s) => Ok(s), + Err(e) => match row.try_get::, _>(i) { + Ok(bytes) => Ok(String::from_utf8_lossy(&bytes).into_owned()), + Err(_) => Err(e.into()), + }, + } +} + +macro_rules! impl_text_query { + ($name:ident, $conn:ty) => { + async fn $name( + conn: &mut $conn, + sql: &str, + binds: &[&str], + width: usize, + ) -> Result>, DbError> { + use tokio_stream::StreamExt; + let mut q = sqlx::query(sql); + for b in binds { + q = q.bind(*b); + } + // Stream row-by-row (not fetch_all) with a running byte budget, so a + // pathologically large result errors instead of buffering to OOM. + let mut stream = q.fetch(conn); + let mut out: Vec> = Vec::new(); + let mut bytes: usize = 0; + while let Some(row) = stream.next().await { + let row = row?; + let cells: Vec = (0..width) + .map(|i| text_cell_at(&row, i)) + .collect::>()?; + bytes = bytes.saturating_add(cells.iter().map(|c| c.len()).sum()); + check_fetch_budget(bytes)?; + out.push(cells); + } + Ok(out) + } + }; +} +impl_text_query!(text_query_sqlite, SqliteConnection); +impl_text_query!(text_query_pg, PgConnection); +impl_text_query!(text_query_mysql, MySqlConnection); + +/// Run a SELECT whose every column is already cast to text. +async fn text_query( + conn: &mut DbConn, + sql: &str, + binds: &[&str], + width: usize, +) -> Result>, DbError> { + match conn { + DbConn::Sqlite(c) => text_query_sqlite(c, sql, binds, width).await, + DbConn::Postgres(c) => text_query_pg(c, sql, binds, width).await, + DbConn::Mysql(c) => text_query_mysql(c, sql, binds, width).await, + } +} + +async fn list_tables(conn: &mut DbConn) -> Result, DbError> { + let sql = match conn { + DbConn::Sqlite(_) => { + "SELECT name FROM sqlite_master WHERE type IN ('table','view') \ + AND name NOT LIKE 'sqlite_%' ORDER BY name" + } + // information_schema identifier columns are domain types (sql_identifier); + // cast so the uniform String decode holds. + DbConn::Postgres(_) => { + "SELECT CAST(table_name AS TEXT) FROM information_schema.tables \ + WHERE table_schema = current_schema() ORDER BY 1" + } + DbConn::Mysql(_) => { + "SELECT CAST(table_name AS CHAR) FROM information_schema.tables \ + WHERE table_schema = DATABASE() ORDER BY 1" + } + }; + Ok( + text_query(conn, sql, &[], 1) + .await? + .into_iter() + .flatten() + .collect(), + ) +} + +async fn list_columns(conn: &mut DbConn, table: &str) -> Result, DbError> { + let sql = match conn { + // pragma_table_info takes the name as a bound value: no identifier splice. + DbConn::Sqlite(_) => "SELECT name FROM pragma_table_info(?1)", + DbConn::Postgres(_) => { + "SELECT CAST(column_name AS TEXT) FROM information_schema.columns \ + WHERE table_schema = current_schema() AND table_name = $1 \ + ORDER BY ordinal_position" + } + DbConn::Mysql(_) => { + "SELECT CAST(column_name AS CHAR) FROM information_schema.columns \ + WHERE table_schema = DATABASE() AND table_name = ? \ + ORDER BY ordinal_position" + } + }; + Ok( + text_query(conn, sql, &[table], 1) + .await? + .into_iter() + .flatten() + .collect(), + ) +} + +async fn fetch_table(conn: &mut DbConn, table: &str) -> Result { + // Membership check against the live table list is what makes the + // identifier splice below safe. + if !list_tables(conn).await?.iter().any(|t| t == table) { + return Err(DbError::UnknownTable(table.to_string())); + } + let headers = list_columns(conn, table).await?; + if headers.is_empty() { + return Err(DbError::UnknownTable(table.to_string())); + } + let dialect = conn.dialect(); + let select_list = headers + .iter() + .map(|c| text_cell(dialect, "e_ident(dialect, c))) + .collect::>() + .join(", "); + // SQL row order is undefined without ORDER BY, so preview/reload/batch could + // see rows flip between fetches. Order by every output column (all text-cast, + // hence always orderable) for a fully deterministic order and truncation. + let order_by = (1..=headers.len()) + .map(|n| n.to_string()) + .collect::>() + .join(", "); + let sql = format!( + "SELECT {select_list} FROM {} ORDER BY {order_by} LIMIT {}", + quote_ident(dialect, table), + ROW_CAP + 1, + ); + let mut rows = text_query(conn, &sql, &[], headers.len()).await?; + let truncated = rows.len() > ROW_CAP; + rows.truncate(ROW_CAP); + Ok(Rows { + headers, + rows, + truncated, + }) +} + +async fn run_list_tables(spec: &DbSpec) -> Result, DbError> { + let mut conn = connect(spec).await?; + let out = with_timeout(QUERY_TIMEOUT, DbError::QueryTimeout, list_tables(&mut conn)).await; + close(conn).await; + out +} + +#[tauri::command] +pub async fn db_list_tables(spec: DbSpec) -> Result, String> { + run_list_tables(&spec).await.map_err(|e| e.to_string()) +} + +async fn run_fetch(spec: &DbSpec, table: &str) -> Result { + let mut conn = connect(spec).await?; + let out = with_timeout( + QUERY_TIMEOUT, + DbError::QueryTimeout, + fetch_table(&mut conn, table), + ) + .await; + close(conn).await; + out +} + +#[tauri::command] +pub async fn db_fetch(spec: DbSpec, table: String) -> Result { + run_fetch(&spec, &table).await.map_err(|e| e.to_string()) +} + +/// Store a network profile's password bound to its endpoint. The webview never +/// sees the value back (IPC read guard), and the binding stops it from being +/// replayed against a different host. +#[tauri::command] +pub async fn db_set_password(spec: DbSpec, password: String) -> Result<(), String> { + let Some((profile_id, endpoint)) = password_binding(&spec) else { + return Err(DbError::SqliteNoPassword.to_string()); + }; + let blob = format!("{endpoint}\n{password}"); + let name = password_cred(profile_id); + blocking(move || credentials::write_password(&name, &blob)) + .await? + .map_err(|e| e.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn rt() -> tokio::runtime::Runtime { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() + } + + async fn seeded_db(dir: &std::path::Path) -> String { + let path = dir.join("test.sqlite").to_string_lossy().into_owned(); + let mut conn = SqliteConnectOptions::new() + .filename(&path) + .create_if_missing(true) + .connect() + .await + .unwrap(); + sqlx::query( + "CREATE TABLE items (sku TEXT, qty INTEGER, price REAL, note TEXT); \ + CREATE TABLE \"odd \"\"name\"\"\" (c TEXT); \ + CREATE VIEW cheap AS SELECT sku FROM items WHERE price < 2;", + ) + .execute(&mut conn) + .await + .unwrap(); + sqlx::query("INSERT INTO items VALUES ('A-1', 3, 1.5, NULL), ('B-2', 0, 2.25, 'x')") + .execute(&mut conn) + .await + .unwrap(); + conn.close().await.unwrap(); + path + } + + async fn open_seeded(tag: &str) -> DbConn { + let dir = std::env::temp_dir().join(format!("zplab-db-test-{tag}")); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = seeded_db(&dir).await; + connect(&DbSpec::Sqlite { path }).await.unwrap() + } + + #[test] + fn non_utf8_blob_decodes_lossy_without_sinking_the_fetch() { + rt().block_on(async { + let dir = std::env::temp_dir().join("zplab-db-test-blob"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("b.sqlite").to_string_lossy().into_owned(); + let mut c = SqliteConnectOptions::new() + .filename(&path) + .create_if_missing(true) + .connect() + .await + .unwrap(); + sqlx::query("CREATE TABLE b (v BLOB)") + .execute(&mut c) + .await + .unwrap(); + sqlx::query("INSERT INTO b VALUES (x'fffe0001'), (x'48656c6c6f')") + .execute(&mut c) + .await + .unwrap(); + c.close().await.unwrap(); + let mut conn = connect(&DbSpec::Sqlite { path }).await.unwrap(); + let got = fetch_table(&mut conn, "b").await.unwrap(); + // Both rows survive (the bad cell doesn't sink the fetch); order is by + // text value, so assert membership rather than position. + assert_eq!(got.rows.len(), 2); + assert!(got.rows.iter().any(|r| r == &vec!["Hello".to_string()])); + }); + } + + #[test] + fn lists_tables_and_views() { + rt().block_on(async { + let mut conn = open_seeded("list").await; + let tables = list_tables(&mut conn).await.unwrap(); + assert_eq!(tables, vec!["cheap", "items", "odd \"name\""]); + }); + } + + #[test] + fn fetches_rows_stringified_with_null_as_empty() { + rt().block_on(async { + let mut conn = open_seeded("fetch").await; + let got = fetch_table(&mut conn, "items").await.unwrap(); + assert_eq!(got.headers, vec!["sku", "qty", "price", "note"]); + assert_eq!(got.rows[0], vec!["A-1", "3", "1.5", ""]); + assert_eq!(got.rows[1], vec!["B-2", "0", "2.25", "x"]); + assert!(!got.truncated); + }); + } + + #[test] + fn fetches_through_a_view_and_quoted_identifiers() { + rt().block_on(async { + let mut conn = open_seeded("view").await; + let view = fetch_table(&mut conn, "cheap").await.unwrap(); + assert_eq!(view.rows, vec![vec!["A-1"]]); + let odd = fetch_table(&mut conn, "odd \"name\"").await.unwrap(); + assert_eq!(odd.headers, vec!["c"]); + }); + } + + #[test] + fn rejects_unknown_table_names() { + rt().block_on(async { + let mut conn = open_seeded("unknown").await; + let err = fetch_table(&mut conn, "items; DROP TABLE items") + .await + .unwrap_err(); + assert!(err.to_string().contains("unknown table")); + }); + } + + #[test] + fn connection_is_read_only() { + rt().block_on(async { + let conn = open_seeded("ro").await; + let DbConn::Sqlite(mut c) = conn else { + panic!("sqlite expected") + }; + let err = sqlx::query("INSERT INTO items VALUES ('C-3', 1, 1.0, NULL)") + .execute(&mut c) + .await; + assert!(err.is_err()); + }); + } + + #[test] + fn quoting_and_cast_follow_the_dialect() { + assert_eq!(quote_ident(Dialect::Ansi, "a\"b"), "\"a\"\"b\""); + assert_eq!(quote_ident(Dialect::MySql, "a`b"), "`a``b`"); + assert_eq!( + text_cell(Dialect::Ansi, "\"c\""), + "COALESCE(CAST(\"c\" AS TEXT), '')" + ); + assert_eq!( + text_cell(Dialect::MySql, "`c`"), + "COALESCE(CAST(`c` AS CHAR), '')" + ); + } + + #[test] + fn specs_deserialize_from_the_webview_shape() { + let pg: DbSpec = serde_json::from_str( + r#"{"driver":"postgres","host":"h","port":5433,"database":"d","user":"u","profileId":"p1"}"#, + ) + .unwrap(); + match pg { + DbSpec::Postgres { + port, + profile_id, + ssl_mode, + .. + } => { + assert_eq!(port, Some(5433)); + assert_eq!(password_cred(&profile_id), "db-profile-p1"); + // Omitted in this payload -> the Prefer default. + assert!(matches!(ssl_mode, SslMode::Prefer)); + } + _ => panic!("postgres expected"), + } + let my: DbSpec = serde_json::from_str( + r#"{"driver":"mysql","host":"h","database":"d","user":"u","profileId":"p2","sslMode":"verify-full"}"#, + ) + .unwrap(); + assert!(matches!( + my, + DbSpec::Mysql { + port: None, + ssl_mode: SslMode::VerifyFull, + .. + } + )); + } + + #[test] + fn password_is_released_only_for_the_bound_endpoint() { + let ep = endpoint_id("db.local", 5432, SslMode::Require, "reader", "sales"); + let blob = format!("{ep}\nsecret"); + // Same endpoint -> released. + assert_eq!( + password_for_endpoint(Some(blob.as_str()), &ep).unwrap(), + Some("secret".into()) + ); + // Redirected host -> refused, secret not leaked. + let other = endpoint_id( + "attacker.example", + 5432, + SslMode::Require, + "reader", + "sales", + ); + assert!(password_for_endpoint(Some(blob.as_str()), &other).is_err()); + // Different port on the same host is also a different endpoint. + assert!(password_for_endpoint( + Some(blob.as_str()), + &endpoint_id("db.local", 5433, SslMode::Require, "reader", "sales") + ) + .is_err()); + // A downgraded ssl_mode is a different endpoint: the secret is withheld so + // it can't be forced onto a plaintext connection to the real host. + assert!(password_for_endpoint( + Some(blob.as_str()), + &endpoint_id("db.local", 5432, SslMode::Disable, "reader", "sales") + ) + .is_err()); + // A different user or database on the same host cannot reuse the secret. + assert!(password_for_endpoint( + Some(blob.as_str()), + &endpoint_id("db.local", 5432, SslMode::Require, "postgres", "sales") + ) + .is_err()); + assert!(password_for_endpoint( + Some(blob.as_str()), + &endpoint_id("db.local", 5432, SslMode::Require, "reader", "payroll") + ) + .is_err()); + // No stored credential -> no password (passwordless connect), not an error. + assert_eq!(password_for_endpoint(None, &ep).unwrap(), None); + // A legacy plain value (no endpoint prefix) refuses rather than leaking. + assert!(password_for_endpoint(Some("bare"), &ep).is_err()); + } + + #[test] + fn password_cred_names_are_caught_by_the_ipc_read_guard() { + // The db password account name and the credentials.rs guard must share the + // prefix, else a compromised webview could read the secret over IPC. + assert!(credentials::is_rust_only(&password_cred("p1"))); + assert!(credentials::is_rust_only(&password_cred("P1"))); + } + + #[test] + fn fetch_budget_rejects_oversize_results() { + assert!(check_fetch_budget(MAX_FETCH_BYTES).is_ok()); + assert!(check_fetch_budget(MAX_FETCH_BYTES + 1).is_err()); + } + + // Live network-driver tests (`--ignored`) against throwaway containers + // (postgres:16 on 5432, mariadb:11 on 3307, db `zpltest`); password from env + // ZPLAB_LIVE_DB_PW, through the real keychain path via a test entry. + fn live_pw() -> String { + std::env::var("ZPLAB_LIVE_DB_PW") + .expect("set ZPLAB_LIVE_DB_PW to the throwaway container password") + } + fn live_spec(driver: &str, port: u16, user: &str, profile_id: &str) -> DbSpec { + serde_json::from_str(&format!( + r#"{{"driver":"{driver}","host":"127.0.0.1","port":{port},"database":"zpltest","user":"{user}","profileId":"{profile_id}"}}"#, + )) + .unwrap() + } + + fn keychain_set(profile_id: &str, port: u16, user: &str, value: &str) { + // Mirror db_set_password: endpoint-bound blob for host 127.0.0.1, db zpltest, + // default ssl_mode (the live specs omit sslMode, so serde yields Prefer). + let blob = format!( + "{}\n{value}", + endpoint_id("127.0.0.1", port, SslMode::Prefer, user, "zpltest") + ); + keyring::Entry::new("ZPLab", &password_cred(profile_id)) + .unwrap() + .set_password(&blob) + .unwrap(); + } + + fn keychain_drop(profile_id: &str) { + let _ = keyring::Entry::new("ZPLab", &password_cred(profile_id)) + .unwrap() + .delete_credential(); + } + + fn assert_live_rows(spec: &DbSpec) { + rt().block_on(async { + let mut conn = connect(spec).await.unwrap(); + let tables = list_tables(&mut conn).await.unwrap(); + assert!(tables.contains(&"items".to_string()), "items in {tables:?}"); + assert!( + tables.contains(&"cheap".to_string()), + "cheap view in {tables:?}" + ); + let got = fetch_table(&mut conn, "items").await.unwrap(); + assert_eq!(got.headers, vec!["sku", "qty", "price", "note", "made"]); + assert_eq!(got.rows[0], vec!["A-1", "3", "1.50", "", "2026-01-15"]); + assert_eq!(got.rows[1], vec!["B-2", "0", "2.25", "x", ""]); + let view = fetch_table(&mut conn, "cheap").await.unwrap(); + assert_eq!(view.rows, vec![vec!["A-1"]]); + // Read-only proof: the session must reject writes server-side. + let write = match &mut conn { + DbConn::Postgres(c) => sqlx::query("INSERT INTO items (sku) VALUES ('X')") + .execute(c) + .await + .err(), + DbConn::Mysql(c) => sqlx::query("INSERT INTO items (sku) VALUES ('X')") + .execute(c) + .await + .err(), + DbConn::Sqlite(_) => unreachable!(), + }; + assert!(write.is_some(), "write must fail on a read-only session"); + close(conn).await; + }); + } + + #[test] + #[ignore = "needs the live postgres container"] + fn live_postgres_end_to_end() { + keychain_set("live-pg", 5432, "postgres", &live_pw()); + assert_live_rows(&live_spec("postgres", 5432, "postgres", "live-pg")); + keychain_drop("live-pg"); + } + + #[test] + #[ignore = "needs the live mariadb container"] + fn live_mariadb_end_to_end() { + keychain_set("live-maria", 3307, "zpl", &live_pw()); + assert_live_rows(&live_spec("mysql", 3307, "zpl", "live-maria")); + keychain_drop("live-maria"); + } + + #[test] + #[ignore = "needs the live postgres container"] + fn live_wrong_password_fails_cleanly() { + keychain_set("live-bad", 5432, "postgres", "wrong"); + let err = rt() + .block_on(connect(&live_spec( + "postgres", 5432, "postgres", "live-bad", + ))) + .err(); + keychain_drop("live-bad"); + let err = err + .expect("connect must fail with a wrong password") + .to_string(); + assert!( + !err.contains("wrong"), + "error must not echo the password: {err}" + ); + } +} diff --git a/src-tauri/src/excel.rs b/src-tauri/src/excel.rs new file mode 100644 index 00000000..7f38d091 --- /dev/null +++ b/src-tauri/src/excel.rs @@ -0,0 +1,232 @@ +//! Excel worksheets as a plain file data source (calamine); same row/cap +//! contract as the db connector, no driver manager. Cells carry their STORED +//! value, not the number-format-rendered text (leading zeros / fixed decimals +//! are lost); full number-format rendering is a deferred follow-up. + +use std::time::Duration; + +use calamine::{open_workbook_auto, Data, Reader}; + +use crate::dataset::{Rows, ROW_CAP}; +use crate::transport::blocking; + +/// Same budget as the db queries. Cannot cancel the blocking parse (calamine +/// materializes the whole sheet), but bounds what the user waits on. +const READ_TIMEOUT: Duration = Duration::from_secs(30); + +/// Compressed on-disk cap (accidental huge files). A label dataset is never +/// this large. NOT a RAM guard on its own: xlsx is a ZIP, so this bounds +/// compressed bytes, not the decompressed size calamine materializes. +const MAX_FILE_BYTES: u64 = 100 * 1024 * 1024; + +/// Decompressed cap. calamine materializes the whole sheet before ROW_CAP and +/// the timeout can't abort it, so a decompression bomb would exhaust RAM; bound +/// the declared uncompressed size up front. Generous vs any real workbook. +const MAX_UNCOMPRESSED_BYTES: u64 = 256 * 1024 * 1024; + +/// Typed reader error; stringified only at the `#[tauri::command]` edge. +#[derive(Debug, thiserror::Error)] +enum ExcelError { + #[error(transparent)] + Io(#[from] std::io::Error), + #[error(transparent)] + Calamine(#[from] calamine::Error), + #[error(transparent)] + Zip(#[from] zip::result::ZipError), + #[error("excel read timed out")] + Timeout, + #[error("file too large: {0} bytes (max {})", MAX_FILE_BYTES)] + TooLarge(u64), + #[error( + "workbook expands to more than {} bytes uncompressed", + MAX_UNCOMPRESSED_BYTES + )] + TooLargeUncompressed, + #[error("empty sheet: {0}")] + EmptySheet(String), + /// The transport::blocking worker thread panicked (stringified JoinError). + /// The one explicit stringly bridge; converted only in timed_read. + #[error("{0}")] + Join(String), +} + +fn check_size(path: &str) -> Result<(), ExcelError> { + let len = std::fs::metadata(path)?.len(); + if len > MAX_FILE_BYTES { + return Err(ExcelError::TooLarge(len)); + } + // xls (CFB) and other non-zip formats aren't compressed, so MAX_FILE_BYTES + // already bounds them; a non-zip open here just skips the expansion check. + let file = std::fs::File::open(path)?; + let Ok(mut zip) = zip::ZipArchive::new(std::io::BufReader::new(file)) else { + return Ok(()); + }; + let mut total: u64 = 0; + for i in 0..zip.len() { + total = total.saturating_add(zip.by_index(i)?.size()); + if total > MAX_UNCOMPRESSED_BYTES { + return Err(ExcelError::TooLargeUncompressed); + } + } + Ok(()) +} + +async fn timed_read( + work: impl std::future::Future, String>>, +) -> Result { + let parsed = tokio::time::timeout(READ_TIMEOUT, work) + .await + .map_err(|_| ExcelError::Timeout)?; + parsed.map_err(ExcelError::Join)? +} + +// calamine parses under the release panic="abort" profile, so a panic on a +// crafted/corrupt workbook aborts the app instead of returning Err. Accepted; +// check_size + the dialog-only pick keep realistic inputs benign. +#[tauri::command] +pub async fn excel_list_sheets(path: String) -> Result, String> { + timed_read(blocking(move || -> Result, ExcelError> { + check_size(&path)?; + let workbook = open_workbook_auto(&path)?; + Ok(workbook.sheet_names().to_vec()) + })) + .await + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn excel_fetch(path: String, sheet: String) -> Result { + timed_read(blocking(move || -> Result { + check_size(&path)?; + let mut workbook = open_workbook_auto(&path)?; + let range = workbook.worksheet_range(&sheet)?; + // The range starts at the first used cell; offset so a synthesized name + // matches the sheet's real column number. + let col_offset = range.start().map_or(0, |(_, c)| c as usize); + let mut rows_iter = range.rows(); + let Some(header_row) = rows_iter.next() else { + return Err(ExcelError::EmptySheet(sheet.clone())); + }; + let headers: Vec = header_row + .iter() + .enumerate() + // Blank header cells still need a stable, mappable name. + .map(|(i, c)| { + let name = cell_text(c); + if name.is_empty() { + format!("Column {}", col_offset + i + 1) + } else { + name + } + }) + .collect(); + let mut truncated = false; + let mut rows: Vec> = Vec::new(); + for row in rows_iter { + if rows.len() >= ROW_CAP { + truncated = true; + break; + } + rows.push(row.iter().map(cell_text).collect()); + } + Ok(Rows { + headers, + rows, + truncated, + }) + })) + .await + .map_err(|e| e.to_string()) +} + +/// Excel's day-zero (the 1900 leap-year bug puts it at 1899-12-30/31); a +/// serial below 1 lands here and means "time, no date". +const EXCEL_EPOCH: chrono::NaiveDate = match chrono::NaiveDate::from_ymd_opt(1899, 12, 31) { + Some(d) => d, + None => unreachable!(), +}; + +fn cell_text(cell: &Data) -> String { + match cell { + Data::Empty => String::new(), + Data::String(s) => s.clone(), + // Excel stores integers as floats; render them without the ".0". + Data::Float(f) if f.fract() == 0.0 && f.abs() < 1e15 => (*f as i64).to_string(), + Data::Float(f) => f.to_string(), + Data::Int(i) => i.to_string(), + Data::Bool(b) => (if *b { "TRUE" } else { "FALSE" }).to_string(), + Data::Error(e) => e.to_string(), + Data::DateTime(dt) => match dt.as_datetime() { + // Date-only cells sit at midnight; keep them free of a phantom time. + Some(ndt) if ndt.time() == chrono::NaiveTime::MIN => ndt.format("%Y-%m-%d").to_string(), + // A pure time is a sub-1 serial, so calamine dates it at the Excel + // epoch (1899-12-30/31); drop that phantom date and emit time only. + Some(ndt) if ndt.date() <= EXCEL_EPOCH => ndt.format("%H:%M:%S").to_string(), + Some(ndt) => ndt.format("%Y-%m-%d %H:%M:%S").to_string(), + None => dt.as_f64().to_string(), + }, + Data::DateTimeIso(s) | Data::DurationIso(s) => s.clone(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn oversize_files_are_rejected_before_parsing() { + let dir = std::env::temp_dir().join("zplab-excel-size"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let small = dir.join("small.bin"); + std::fs::write(&small, b"hi").unwrap(); + // Non-zip small file: passes both caps (zip open is skipped). + assert!(check_size(&small.to_string_lossy()).is_ok()); + assert!(check_size(&dir.join("missing.bin").to_string_lossy()).is_err()); + } + + #[test] + fn a_normal_zip_workbook_passes_the_expansion_guard() { + use std::io::Write; + let dir = std::env::temp_dir().join("zplab-excel-zip"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let p = dir.join("small.xlsx"); + let mut zw = zip::ZipWriter::new(std::fs::File::create(&p).unwrap()); + zw.start_file( + "xl/worksheets/sheet1.xml", + zip::write::SimpleFileOptions::default(), + ) + .unwrap(); + zw.write_all(b"").unwrap(); + zw.finish().unwrap(); + assert!(check_size(&p.to_string_lossy()).is_ok()); + } + + #[test] + fn cell_text_covers_the_data_variants() { + assert_eq!(cell_text(&Data::Empty), ""); + assert_eq!(cell_text(&Data::String("x".into())), "x"); + assert_eq!(cell_text(&Data::Float(3.0)), "3"); + assert_eq!(cell_text(&Data::Float(3.25)), "3.25"); + assert_eq!(cell_text(&Data::Int(7)), "7"); + assert_eq!(cell_text(&Data::Bool(true)), "TRUE"); + assert_eq!( + cell_text(&Data::DateTimeIso("2026-07-20T10:00".into())), + "2026-07-20T10:00" + ); + } + + #[test] + fn time_only_cell_drops_the_phantom_epoch_date() { + // Serial 0.395833… = 09:30, no date component. + assert_eq!( + cell_text(&Data::DateTime(calamine::ExcelDateTime::new( + 0.395_833_333_333_333_3, + calamine::ExcelDateTimeType::DateTime, + false, + ))), + "09:30:00" + ); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 273f3f2f..20080aea 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,4 +1,7 @@ mod credentials; +mod dataset; +mod db; +mod excel; mod mcp; mod print; mod transport; @@ -19,6 +22,11 @@ pub fn run() { usb::send_zpl_usb, usb::query_zpl_usb, usb::setup_usb_access, + db::db_list_tables, + db::db_fetch, + db::db_set_password, + excel::excel_list_sheets, + excel::excel_fetch, credentials::credential_get, credentials::credential_set, credentials::credential_delete, diff --git a/src/components/AppShell.tsx b/src/components/AppShell.tsx index 4678b98c..77e14380 100644 --- a/src/components/AppShell.tsx +++ b/src/components/AppShell.tsx @@ -12,6 +12,7 @@ import { ZPLOutput } from "./Output/ZPLOutput"; import { ZplImportModal } from "./Output/ZplImportModal"; import { VariableMappingModal } from "./Variables/VariableMappingModal"; import { CsvImportConfirmDialog } from "./Variables/CsvImportConfirmDialog"; +import { ExcelSheetModal } from "./Variables/ExcelSheetModal"; import { PrintToZebraDialog } from "./Output/PrintToZebraDialog"; import { DropdownMenu, @@ -40,7 +41,9 @@ import { MoonIcon, GlobeAltIcon, } from "@heroicons/react/16/solid"; -import { useLabelStore, useHistory, selectLabelaryNoticeRequired, selectPreviewLocksEditor } from "../store/labelStore"; +import { useLabelStore, useHistory, selectLabelaryNoticeRequired, selectPreviewLocksEditor, selectBatchPrintCount } from "../store/labelStore"; +import { datasetTimestamp } from "@zplab/core/types/DataSource"; +import { isCurrentDataContext } from "../store/datasetActions"; import { formatTemplate } from "../lib/formatTemplate"; import { buildMenuModel, type MenuItemId } from "../lib/menuModel"; import { NativeMenuBridge } from "./NativeMenuBridge"; @@ -60,6 +63,7 @@ import { useGlobalShortcuts } from "../hooks/useGlobalShortcuts"; import { useDesignFileActions } from "../hooks/useDesignFileActions"; import { useMcpBridge } from "../hooks/useMcpBridge"; import { useCsvImportActions } from "../hooks/useCsvImportActions"; +import { useExcelImportActions } from "../hooks/useExcelImportActions"; import { useZplImportExport } from "../hooks/useZplImportExport"; import { useOutputPanel, OUTPUT_DEFAULT_H } from "../hooks/useOutputPanel"; import { useCollapsiblePanel } from "../hooks/useCollapsiblePanel"; @@ -103,6 +107,7 @@ const MENU_ICONS: Partial s.label); + const batchPrintCount = useLabelStore(selectBatchPrintCount); const pages = useLabelStore((s) => s.pages); const selectObject = useLabelStore((s) => s.selectObject); const addPage = useLabelStore((s) => s.addPage); @@ -172,13 +178,15 @@ export function AppShell() { confirmPendingImport, cancelPendingImport, } = useCsvImportActions(); - const csvMappingModalOpen = useLabelStore((s) => s.csvMappingModalOpen); - const closeCsvMappingModal = useLabelStore((s) => s.closeCsvMappingModal); + const { openExcelPicker, pendingExcel, loadSheet, cancelExcelImport } = + useExcelImportActions(); + const mappingModalOpen = useLabelStore((s) => s.mappingModalOpen); + const closeMappingModal = useLabelStore((s) => s.closeMappingModal); // Remount the mapping modal when the dataset changes (e.g. importing from its - // own no-CSV state) so its open-time draft re-seeds with the new CSV. - // importedAt is unique per import, so it also catches re-importing the same - // filename (which a filename key would miss). - const csvDatasetKey = useLabelStore((s) => s.csvDataset?.source.importedAt); + // own empty state) so its open-time draft re-seeds with the new data. The + // timestamp is unique per import/fetch, so it also catches re-loading the + // same file or table (which a name key would miss). + const datasetKey = useLabelStore((s) => s.dataset && datasetTimestamp(s.dataset.source)); const { showZplImport, openZplImport, @@ -203,6 +211,8 @@ export function AppShell() { hasObjects, canBatchExport, batchRowCount, + batchPrintCount, + includeExcelImport: isDesktopShell, labelaryEnabled, canUndo, canRedo, @@ -219,6 +229,7 @@ export function AppShell() { openDesign: handleOpen, saveDesign: handleSave, importCsv: openCsvPicker, + importExcel: openExcelPicker, // Print routes through Labelary; clicking before the notice has been // acknowledged opens the disclosure first, then prints. print: () => (noticeRequired ? setShowPrintNotice(true) : void handlePrint()), @@ -509,14 +520,26 @@ export function AppShell() { {showZplImport && } - {csvMappingModalOpen && ( + {mappingModalOpen && ( )} - {pendingImport && ( + {/* Token gate: a doc/context swap leaves the local pending state stale, so + hide the dialog rather than let it hover over the new document. */} + {pendingExcel && isCurrentDataContext(pendingExcel.token) && ( + + )} + {pendingImport && isCurrentDataContext(pendingImport.token) && ( s.csvDataset); - const csvMapping = useLabelStore((s) => s.csvMapping); - const markerErrors = typedContentMarkerFindings(type, fields, variables, csvDataset, csvMapping); + const dataset = useLabelStore((s) => s.dataset); + const columnMapping = useLabelStore((s) => s.columnMapping); + const markerErrors = typedContentMarkerFindings(type, fields, variables, dataset, columnMapping); const valid = isContentComplete(type, validationFields) && Object.keys(markerErrors).length === 0; const ec = recommendedEc(resolveDefaults(content)); // EC recommendation is QR-only; DataMatrix uses fixed ECC200. diff --git a/src/components/Canvas/KonvaObject.tsx b/src/components/Canvas/KonvaObject.tsx index 624b146c..17ee93ea 100644 --- a/src/components/Canvas/KonvaObject.tsx +++ b/src/components/Canvas/KonvaObject.tsx @@ -449,17 +449,17 @@ const BARCODE_TYPES = new Set([ export function KonvaObject(props_: Props) { const { variables, active, clock } = usePreviewBinding(); - const csvDataset = useLabelStore((s) => s.csvDataset); - const csvMapping = useLabelStore((s) => s.csvMapping); - const csvRenderMode = useLabelStore((s) => s.canvasSettings.csvRenderMode); - const obj = applyBindingToObject(props_.obj, variables, active, csvRenderMode, clock, objectResolvesCtrl(props_.obj)); + const dataset = useLabelStore((s) => s.dataset); + const columnMapping = useLabelStore((s) => s.columnMapping); + const dataRenderMode = useLabelStore((s) => s.canvasSettings.dataRenderMode); + const obj = applyBindingToObject(props_.obj, variables, active, dataRenderMode, clock, objectResolvesCtrl(props_.obj)); const renderProps = obj === props_.obj ? props_ : { ...props_, obj }; // Classify the ORIGINAL content: `obj` has had its markers resolved to print // values by applyBindingToObject, so its content no longer matches `«name»`. const boundVariable = lookupBoundVariable(props_.obj, variables); const showFallbackTint = shouldShowFallbackTint( - boundVariable, csvDataset, csvMapping, csvRenderMode, + boundVariable, dataset, columnMapping, dataRenderMode, ); const shape = diff --git a/src/components/Canvas/LabelCanvas.tsx b/src/components/Canvas/LabelCanvas.tsx index 1f779444..52795a61 100644 --- a/src/components/Canvas/LabelCanvas.tsx +++ b/src/components/Canvas/LabelCanvas.tsx @@ -247,8 +247,8 @@ export const LabelCanvas = forwardRef(function LabelCa const previewBinding = usePreviewBinding(); // Raw dataset/mapping (not just the active row): markerValueFindings // validates marker values across ALL CSV rows, since any row prints. - const csvDataset = useLabelStore((s) => s.csvDataset); - const csvMapping = useLabelStore((s) => s.csvMapping); + const dataset = useLabelStore((s) => s.dataset); + const columnMapping = useLabelStore((s) => s.columnMapping); const paletteRows = useLabelStore((s) => s.paletteRows); const previewMode = useLabelStore((s) => s.previewMode); const previewLocks = useLabelStore(selectPreviewLocksEditor); @@ -614,8 +614,8 @@ export const LabelCanvas = forwardRef(function LabelCa ...barcodeEncodeFindings(preflightLeaves, scale, label.dpmm, previewBinding), ...markerValueFindings(preflightLeaves, { variables: previewBinding.variables, - csvDataset, - csvMapping, + dataset, + columnMapping, }), ], pristineEmptyIds, diff --git a/src/components/Canvas/barcodePreflight.ts b/src/components/Canvas/barcodePreflight.ts index 08b56a88..5d156e9f 100644 --- a/src/components/Canvas/barcodePreflight.ts +++ b/src/components/Canvas/barcodePreflight.ts @@ -5,7 +5,7 @@ import type { Variable } from "@zplab/core/types/Variable"; import { applyBindingToObject, getObjectStringContent, - type ActiveCsvRow, + type ActiveRow, type ClockResolveCtx, } from "@zplab/core/lib/variableBinding"; import { renderBarcodeCanvas } from "./bwipHelpers"; @@ -16,7 +16,7 @@ import { renderBarcodeCanvas } from "./bwipHelpers"; * too long. */ export interface EncodeEnv { variables: readonly Variable[]; - active: ActiveCsvRow | null; + active: ActiveRow | null; clock?: ClockResolveCtx; } diff --git a/src/components/History/HistoryDropdown.tsx b/src/components/History/HistoryDropdown.tsx index c1255c25..5ea0ae65 100644 --- a/src/components/History/HistoryDropdown.tsx +++ b/src/components/History/HistoryDropdown.tsx @@ -35,7 +35,7 @@ const KIND_ICON: Record> group: RectangleGroupIcon, reorder: ArrowsUpDownIcon, variable: VariableIcon, - csv: TableCellsIcon, + dataset: TableCellsIcon, label: Cog6ToothIcon, page: DocumentIcon, mixed: EllipsisHorizontalIcon, diff --git a/src/components/Output/PrintToZebraDialog.batchNotice.test.tsx b/src/components/Output/PrintToZebraDialog.batchNotice.test.tsx new file mode 100644 index 00000000..01fc1c87 --- /dev/null +++ b/src/components/Output/PrintToZebraDialog.batchNotice.test.tsx @@ -0,0 +1,72 @@ +// @vitest-environment jsdom +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { render, cleanup, act } from '@testing-library/react'; +import { PrintToZebraDialog } from './PrintToZebraDialog'; +import { useLabelStore } from '../../store/labelStore'; +import { fallbackTranslations as en } from '../../locales'; + +afterEach(cleanup); + +const dataset = { + headers: ['sku'], + rows: [['A1'], ['B2'], ['C3']], + source: { + kind: 'csv' as const, + filename: 't.csv', + importedAt: '', + encoding: 'utf-8', + delimiter: ',', + rowCount: 3, + }, + activeRowIndex: 0, +}; +const variables = [{ id: 'v1', name: 'sku', fnNumber: 1, defaultValue: '' }]; +const mapping = { bindings: { v1: 'sku' }, headerSnapshot: ['sku'] }; + +const noticeText = () => en.zebraPrint.batchNoticeFmt.replace('{n}', '3'); + +// Regression: the batch send-notice guards against an unaware bulk send, so it +// must appear iff a mapped dataset drives the label source. +describe('PrintToZebraDialog batch notice', () => { + it('shows the row-count notice for a mapped dataset on the label source', () => { + act(() => { + useLabelStore.setState({ zebraPrintSource: 'label', dataset, columnMapping: mapping, variables }); + }); + const { container } = render(); + expect(container.textContent).toContain(noticeText()); + }); + + it('multiplies by the per-label print quantity (^PQ rides every recall)', () => { + act(() => { + useLabelStore.setState({ + zebraPrintSource: 'label', + dataset, + columnMapping: mapping, + variables, + label: { widthMm: 50, heightMm: 30, dpmm: 8, printQuantity: 4 }, + }); + }); + const { container } = render(); + const expected = en.zebraPrint.batchNoticeQtyFmt + .replace('{n}', '12') + .replace('{rows}', '3') + .replace('{q}', '4'); + expect(container.textContent).toContain(expected); + }); + + it('hides the notice for the setup-script source', () => { + act(() => { + useLabelStore.setState({ zebraPrintSource: 'setupScript', dataset, columnMapping: mapping }); + }); + const { container } = render(); + expect(container.textContent).not.toContain(noticeText()); + }); + + it('hides the notice without a mapped dataset', () => { + act(() => { + useLabelStore.setState({ zebraPrintSource: 'label', dataset: null, columnMapping: null }); + }); + const { container } = render(); + expect(container.textContent).not.toContain('one per data row'); + }); +}); diff --git a/src/components/Output/PrintToZebraDialog.tsx b/src/components/Output/PrintToZebraDialog.tsx index 6fc61723..1213d3c7 100644 --- a/src/components/Output/PrintToZebraDialog.tsx +++ b/src/components/Output/PrintToZebraDialog.tsx @@ -12,6 +12,8 @@ import { } from "../../lib/zebraPrint"; import { isDesktopShell } from "../../lib/platform"; import { getPrinterAddress, setPrinterAddress } from "../../lib/printerAddress"; +import { useLabelStore, selectBatchInputs, selectBatchPrintCount } from "../../store/labelStore"; +import { formatTemplate } from "../../lib/formatTemplate"; import { listLocalPrinters, sendZplLocal, isLikelyZebra, type LocalPrinter } from "../../lib/localPrint"; import { sendZplUsb, setupUsbAccess, isLikelyZebra as isUsbZebra } from "../../lib/usbPrint"; import { printerOptionLabel } from "../../lib/printerLabel"; @@ -82,6 +84,15 @@ interface Props { export function PrintToZebraDialog({ zpl, onClose }: Props) { const t = useT(); const [tab, setTab] = useState("network"); + // The label source silently switches to batch form when a dataset is + // mapped; surface the count so nobody sends 10k labels unaware. A per-label + // ^PQ rides the stored template and multiplies EVERY recall, so the honest + // number (selectBatchPrintCount) is rows × quantity. + const batchRows = useLabelStore((s) => + s.zebraPrintSource === "label" ? selectBatchInputs(s)?.dataset.rows.length ?? null : null, + ); + const printQuantity = useLabelStore((s) => s.label.printQuantity ?? 1); + const batchCount = useLabelStore(selectBatchPrintCount); // Network tab state; the address is shared with the preview provider. const [ip, setIp] = useState(() => getPrinterAddress().host); @@ -390,6 +401,18 @@ export function PrintToZebraDialog({ zpl, onClose }: Props) { + {batchRows !== null && ( +

+ {printQuantity > 1 + ? formatTemplate(t.zebraPrint.batchNoticeQtyFmt, { + n: String(batchCount), + rows: String(batchRows), + q: String(printQuantity), + }) + : formatTemplate(t.zebraPrint.batchNoticeFmt, { n: String(batchRows) })} +

+ )} + {/* Tabs */}
{tabs diff --git a/src/components/PrinterSettings/DataSourcesTab.tsx b/src/components/PrinterSettings/DataSourcesTab.tsx new file mode 100644 index 00000000..b3eda112 --- /dev/null +++ b/src/components/PrinterSettings/DataSourcesTab.tsx @@ -0,0 +1,426 @@ +import { useEffect, useState } from 'react'; +import { CircleStackIcon, PlusIcon, TrashIcon } from '@heroicons/react/16/solid'; +import { useLabelStore } from '../../store/labelStore'; +import { useT } from '../../hooks/useT'; +import { useDbConnectActions } from '../../hooks/useDbConnectActions'; +import { + dbListTables, + dbPasswordCred, + dbSetPassword, + enqueueCredWrite, +} from '../../lib/db'; +import { deleteCredential } from '../../lib/credentialStore'; +import { pickFilePath, SQLITE_FILTER } from '../../lib/fileDialogs'; +import { formatTemplate } from '../../lib/formatTemplate'; +import { ConfirmDialog } from '../ui/ConfirmDialog'; +import { Select } from '../ui/Select'; +import { inputCls } from '../Properties/styles'; +import type { DbProfile, DbSslMode } from '../../lib/db'; + +type Driver = DbProfile['driver']; + +/** Settings tab: pick-or-create a connection profile, browse its tables, + * load rows as the session dataset (closes the settings modal, the mapping + * review takes over). Desktop-only via TAB_GATES. */ +export function DataSourcesTab() { + const t = useT(); + const tv = t.variables; + const dbProfiles = useLabelStore((s) => s.dbProfiles); + const addDbProfile = useLabelStore((s) => s.addDbProfile); + const updateDbProfile = useLabelStore((s) => s.updateDbProfile); + const removeDbProfile = useLabelStore((s) => s.removeDbProfile); + const dataSourceRef = useLabelStore((s) => s.dataSourceRef); + const setPrinterSettingsTab = useLabelStore((s) => s.setPrinterSettingsTab); + const { loadFromDb } = useDbConnectActions(); + + const [selectedId, setSelectedId] = useState(() => { + // A saved link whose profile was deleted must not preselect a ghost id + // (empty select trigger, hidden form); fall back to the first profile. + const refId = dataSourceRef?.profileId; + return (refId && dbProfiles.some((p) => p.id === refId) ? refId : dbProfiles[0]?.id) ?? ''; + }); + const profile = dbProfiles.find((p) => p.id === selectedId) ?? null; + + const [selectedTable, setSelectedTable] = useState( + () => (dataSourceRef?.profileId === selectedId ? dataSourceRef?.table ?? '' : ''), + ); + const [loading, setLoading] = useState(false); + const [pendingDelete, setPendingDelete] = useState(null); + // Local mirror of the password input; committed to the keychain on blur, + // never into the store. Empty means "keep whatever is stored". + const [passwordDraft, setPasswordDraft] = useState(''); + // Bumped when a new password lands so a previously-failed table fetch retries. + const [credNonce, setCredNonce] = useState(0); + + const connectionReady = + profile !== null && + (profile.driver === 'sqlite' + ? profile.path !== '' + : profile.host !== '' && profile.database !== '' && profile.user !== ''); + + // Keyed by full connection identity so edits refetch but a rename doesn't; + // a key mismatch is a stale result, reset derived in render (not via effect). + const tablesKey = profile + ? `${credNonce} ${JSON.stringify({ ...profile, name: undefined })}` + : ''; + const [tablesResult, setTablesResult] = useState<{ + key: string; + tables?: string[]; + error?: string; + } | null>(null); + const tables = tablesResult?.key === tablesKey ? tablesResult.tables ?? null : null; + const tablesError = tablesResult?.key === tablesKey ? tablesResult.error ?? null : null; + // The selection is only valid once it appears in the CURRENT connection's + // table list; while tables are (re)loading or errored, treat it as unset so + // Load can't fire against a stale table the (empty) Select no longer shows. + const liveTable = tables?.includes(selectedTable) ? selectedTable : ''; + useEffect(() => { + if (!connectionReady) return; + let cancelled = false; + // Debounced: tablesKey changes on every keystroke and each listing is a real + // connect()+login, so without the delay editing a field fires a burst of + // failed logins that can trip server-side lockout / fail2ban. + const timer = setTimeout(() => { + // Re-read from the store so the async callback can't use a stale closure. + const current = useLabelStore.getState().dbProfiles.find((p) => p.id === selectedId); + if (!current) return; + const key = tablesKey; + dbListTables(current) + .then((ts) => { + if (cancelled) return; + // liveTable masks a stale selection at every consumer, so no prune here. + setTablesResult({ key, tables: ts }); + }) + .catch((e: unknown) => { + if (!cancelled) setTablesResult({ key, error: String(e) }); + }); + }, 500); + return () => { + cancelled = true; + clearTimeout(timer); + }; + }, [connectionReady, selectedId, tablesKey]); + + const handleAdd = () => { + const created: DbProfile = { + id: crypto.randomUUID(), + name: nextProfileName(dbProfiles, tv.dbProfileLabel), + driver: 'sqlite', + path: '', + }; + addDbProfile(created); + setSelectedId(created.id); + setSelectedTable(''); + }; + + const handleDriverChange = (driver: Driver) => { + if (!profile || driver === profile.driver) return; + // Leaving the network drivers orphans the keychain password; drop it so a + // later switch back can't silently reuse a stale secret. + if (driver === 'sqlite' && profile.driver !== 'sqlite') { + void deleteStoredPassword(profile.id); + } + const base = { id: profile.id, name: profile.name }; + updateDbProfile( + driver === 'sqlite' + ? { ...base, driver, path: '' } + : { ...base, driver, host: '', database: '', user: '' }, + ); + setSelectedTable(''); + setPasswordDraft(''); + }; + + const handleBrowse = () => { + void pickFilePath(SQLITE_FILTER).then((path) => { + if (path && profile?.driver === 'sqlite') updateDbProfile({ ...profile, path }); + }); + }; + + // Keychain writes serialize through the module-level queue in lib/db (shared + // with the reconnect chip). A failed save leaves the keychain unchanged, so a + // later Load just authenticates with the old password and surfaces the error. + const credError = (e: unknown) => + useLabelStore + .getState() + .setUserError(formatTemplate(tv.dbPasswordSaveErrorFmt, { error: String(e) })); + + const commitPassword = () => { + if (!profile || profile.driver === 'sqlite' || passwordDraft === '') return; + const netProfile = profile; + const value = passwordDraft; + // dbSetPassword (not setCredentialExact): stores the password endpoint-bound + // in Rust so it can't be replayed against another host. Whitespace is kept. + void enqueueCredWrite(() => + dbSetPassword(netProfile, value).then(() => { + setPasswordDraft(''); + setCredNonce((n) => n + 1); + }, credError), // draft stays so the user can retry + ); + }; + + const clearStoredPassword = () => { + if (!profile) return; + const id = profile.id; + setPasswordDraft(''); + void enqueueCredWrite(() => + deleteCredential(dbPasswordCred(id)).then(() => setCredNonce((n) => n + 1), credError), + ); + }; + + const deleteStoredPassword = (id: string): Promise => + enqueueCredWrite(() => deleteCredential(dbPasswordCred(id)).catch(credError)); + + const handleLoad = () => { + if (!profile || liveTable === '') return; + setLoading(true); + void (async () => { + // loadFromDb awaits the cred-write queue itself, so a just-typed password + // still lands before the connect; no second await needed here. + const ok = await loadFromDb(profile, liveTable); + setLoading(false); + // Hand over to the mapping review; settings stay open on failure so + // the user can correct the connection. + if (ok) setPrinterSettingsTab(null); + })(); + }; + + const fieldLabel = 'text-[10px] text-muted uppercase tracking-wider'; + + return ( +
+
+
+ + {dbProfiles.length === 0 ? ( +

{tv.dbNoProfiles}

+ ) : ( + + value={selectedId} + onChange={(id) => { + setSelectedId(id); + setPasswordDraft(''); + setSelectedTable( + dataSourceRef?.profileId === id ? dataSourceRef.table : '', + ); + }} + groups={[ + { + options: dbProfiles.map((p) => ({ value: p.id, label: p.name })), + }, + ]} + /> + )} +
+ +
+ + {profile && ( + <> +
+ +
+ updateDbProfile({ ...profile, name: e.target.value })} + /> + +
+
+ +
+ + + value={profile.driver} + onChange={handleDriverChange} + groups={[ + { + options: [ + { value: 'sqlite', label: tv.dbDriverSqlite }, + { value: 'postgres', label: tv.dbDriverPostgres }, + { value: 'mysql', label: tv.dbDriverMysql }, + ], + }, + ]} + /> +
+ + {profile.driver === 'sqlite' ? ( +
+ +
+ + +
+
+ ) : ( + <> +
+
+ + updateDbProfile({ ...profile, host: e.target.value })} + /> +
+
+ + { + const n = parseInt(e.target.value, 10); + updateDbProfile({ + ...profile, + // Rust deserializes into u16; clamp instead of an + // opaque serde error at fetch time. + port: Number.isNaN(n) ? undefined : Math.min(65535, Math.max(1, n)), + }); + }} + /> +
+
+
+ + updateDbProfile({ ...profile, database: e.target.value })} + /> +
+
+ + updateDbProfile({ ...profile, user: e.target.value })} + /> +
+
+ + setPasswordDraft(e.target.value)} + onBlur={commitPassword} + autoComplete="off" + /> +

+ {tv.dbPasswordStoredHint} +

+ +
+ +
+ + + value={profile.sslMode ?? 'prefer'} + onChange={(sslMode) => updateDbProfile({ ...profile, sslMode })} + groups={[ + { + options: (['prefer', 'require', 'verify-full', 'disable'] as const).map( + (m) => ({ value: m, label: m }), + ), + }, + ]} + /> +
+ + )} + +
+ + {tablesError ? ( +

+ {formatTemplate(tv.dbTablesErrorFmt, { error: tablesError })} +

+ ) : ( + + value={liveTable} + onChange={setSelectedTable} + groups={[ + { + options: [ + { value: '', label: '—' }, + ...(tables ?? []).map((name) => ({ value: name, label: name })), + ], + }, + ]} + /> + )} +
+ +
+ +
+ + )} + + {pendingDelete && ( + { + removeDbProfile(pendingDelete.id); + // Drop the orphaned keychain password with the profile. + void deleteStoredPassword(pendingDelete.id); + setPendingDelete(null); + setSelectedId((cur) => { + if (cur !== pendingDelete.id) return cur; + const rest = dbProfiles.filter((p) => p.id !== pendingDelete.id); + return rest[0]?.id ?? ''; + }); + }} + onCancel={() => setPendingDelete(null)} + /> + )} +
+ ); +} + +function nextProfileName(existing: readonly DbProfile[], base: string): string { + const taken = new Set(existing.map((p) => p.name)); + let i = 1; + while (taken.has(`${base} ${i}`)) i++; + return `${base} ${i}`; +} diff --git a/src/components/PrinterSettings/McpServerTab.test.tsx b/src/components/PrinterSettings/McpServerTab.test.tsx index 0ffe0e11..0418aab1 100644 --- a/src/components/PrinterSettings/McpServerTab.test.tsx +++ b/src/components/PrinterSettings/McpServerTab.test.tsx @@ -42,10 +42,10 @@ describe("McpServerTab", () => { describe("mcpServer tab gate", () => { it("is visible only when the build can spawn the sidecar", () => { const gate = TAB_GATES.mcpServer; - expect(gate?.({ mcpSidecarAvailable: true })).toBe(true); + expect(gate?.({ mcpSidecarAvailable: true, isDesktop: true })).toBe(true); // Web/unbundled release report false; boot ping not yet resolved is null. - expect(gate?.({ mcpSidecarAvailable: false })).toBe(false); - expect(gate?.({ mcpSidecarAvailable: null })).toBe(false); + expect(gate?.({ mcpSidecarAvailable: false, isDesktop: true })).toBe(false); + expect(gate?.({ mcpSidecarAvailable: null, isDesktop: true })).toBe(false); }); }); diff --git a/src/components/PrinterSettings/PrinterSettingsModal.tsx b/src/components/PrinterSettings/PrinterSettingsModal.tsx index 3c528b76..f3293b92 100644 --- a/src/components/PrinterSettings/PrinterSettingsModal.tsx +++ b/src/components/PrinterSettings/PrinterSettingsModal.tsx @@ -5,6 +5,7 @@ import { useMcpAvailability } from "../../hooks/useMcpServer"; import { useT } from "../../hooks/useT"; import { generateSetupScript } from "../../lib/zplSetupScript"; import { useLabelStore, selectHasPerLabelOverrides } from "../../store/labelStore"; +import { isDesktopShell } from "../../lib/platform"; import type { PrinterSettingsTab } from "../../store/slices/uiSlice"; import { TAB_GATES, type TabGateCtx } from "./tabVisibility"; import type { PrinterProfile } from "@zplab/core/types/PrinterProfile"; @@ -13,6 +14,7 @@ import { Tooltip } from "../ui/Tooltip"; import { ZplLine } from "../Output/ZplLine"; import { AppSettingsTab } from "./AppSettingsTab"; import { ClockAndTimeTab } from "./ClockAndTimeTab"; +import { DataSourcesTab } from "./DataSourcesTab"; import { EncodingAndLanguageTab } from "./EncodingAndLanguageTab"; import { FontsTab } from "./FontsTab"; import { IdentityTab } from "./IdentityTab"; @@ -32,6 +34,7 @@ const TOP_TAB_OF = { appSettings: 'app', previewSettings: 'app', mcpServer: 'app', + dataSources: 'app', mediaFeed: 'perLabel', printQuality: 'perLabel', output: 'perLabel', @@ -65,6 +68,7 @@ const TAB_COMPONENTS: Partial> = { appSettings: AppSettingsTab, previewSettings: PreviewSettingsTab, mcpServer: McpServerTab, + dataSources: DataSourcesTab, mediaFeed: MediaFeedTab, printQuality: PrintQualityTab, output: OutputTab, @@ -129,7 +133,7 @@ export function PrinterSettingsModal() { }; const hasPerLabelOverrides = useLabelStore(selectHasPerLabelOverrides); const mcpSidecarAvailable = useLabelStore((s) => s.mcpSidecarAvailable); - const gateCtx: TabGateCtx = { mcpSidecarAvailable }; + const gateCtx: TabGateCtx = { mcpSidecarAvailable, isDesktop: isDesktopShell }; const openZebraPrint = useLabelStore((s) => s.openZebraPrint); const titleId = useId(); const subtitleId = useId(); diff --git a/src/components/PrinterSettings/tabVisibility.ts b/src/components/PrinterSettings/tabVisibility.ts index bfb5dde2..2f8b877f 100644 --- a/src/components/PrinterSettings/tabVisibility.ts +++ b/src/components/PrinterSettings/tabVisibility.ts @@ -4,9 +4,12 @@ import type { PrinterSettingsTab } from "../../store/slices/uiSlice"; * Gates both the rail entry and the content pane. */ export interface TabGateCtx { mcpSidecarAvailable: boolean | null; + isDesktop: boolean; } export const TAB_GATES: Partial boolean>> = { // Web and sidecar-less releases report unavailable, so one fact gates both. mcpServer: (ctx) => ctx.mcpSidecarAvailable === true, + // Rust connector only; web has no database access. + dataSources: (ctx) => ctx.isDesktop, }; diff --git a/src/components/Properties/VariableCsvPanel.tsx b/src/components/Properties/VariableCsvPanel.tsx index defe62e3..ea8d6202 100644 --- a/src/components/Properties/VariableCsvPanel.tsx +++ b/src/components/Properties/VariableCsvPanel.tsx @@ -1,8 +1,10 @@ import { TableCellsIcon, ArrowTopRightOnSquareIcon } from "@heroicons/react/16/solid"; import { useT } from "../../hooks/useT"; -import { useLabelStore } from "../../store/labelStore"; +import { useLabelStore, selectPreviewLocksEditor } from "../../store/labelStore"; +import { datasetDisplayName } from "@zplab/core/types/DataSource"; +import { isDesktopShell } from "../../lib/platform"; import { - buildActiveCsvRow, + buildActiveRow, getVariableSource, resolveVariableValue, } from "@zplab/core/lib/variableBinding"; @@ -21,15 +23,23 @@ export function VariableCsvPanel({ }) { const t = useT(); const tv = t.variableBuilder; - const csvDataset = useLabelStore((s) => s.csvDataset); - const csvMapping = useLabelStore((s) => s.csvMapping); + const dataset = useLabelStore((s) => s.dataset); + const columnMapping = useLabelStore((s) => s.columnMapping); const variables = useLabelStore((s) => s.variables); const setActiveRow = useLabelStore((s) => s.setActiveRow); - const openCsvMappingModal = useLabelStore((s) => s.openCsvMappingModal); + const openMappingModal = useLabelStore((s) => s.openMappingModal); + const dataSourceRef = useLabelStore((s) => s.dataSourceRef); + // setActiveRow no-ops under the preview lock, so match VariablesPanel and + // disable the steppers rather than leave a dead affordance. + const previewLocked = useLabelStore(selectPreviewLocksEditor); + const setPrinterSettingsTab = useLabelStore((s) => s.setPrinterSettingsTab); const goManage = () => { onLeave(); - openCsvMappingModal(); + // A db-linked design without loaded rows dead-ends in the mapping + // modal's CSV shell; route to the reconnect surface instead. + if (!dataset && dataSourceRef && isDesktopShell) setPrinterSettingsTab('dataSources'); + else openMappingModal(); }; const header = ( @@ -42,7 +52,7 @@ export function VariableCsvPanel({
); - if (!csvDataset) { + if (!dataset) { return (
{header} @@ -51,13 +61,13 @@ export function VariableCsvPanel({ ); } - const total = csvDataset.rows.length; - const mapped = variables.filter((v) => getVariableSource(v, csvDataset, csvMapping) === "csv").length; - const active = buildActiveCsvRow(csvDataset, csvMapping); - const rowNo = total > 0 ? csvDataset.activeRowIndex + 1 : 0; + const total = dataset.rows.length; + const mapped = variables.filter((v) => getVariableSource(v, dataset, columnMapping) === "bound").length; + const active = buildActiveRow(dataset, columnMapping); + const rowNo = total > 0 ? dataset.activeRowIndex + 1 : 0; const selectedVar = selectedVarName ? variables.find((v) => v.name === selectedVarName) : undefined; - const selHeader = selectedVar ? csvMapping?.bindings[selectedVar.id] : undefined; + const selHeader = selectedVar ? columnMapping?.bindings[selectedVar.id] : undefined; const selValue = selectedVar && active ? resolveVariableValue(selectedVar, active, "preview") : ""; return ( @@ -66,16 +76,16 @@ export function VariableCsvPanel({
- {csvDataset.source.filename} + {datasetDisplayName(dataset.source)} {total} {tv.rowsLabel}
{mapped} / {variables.length} {tv.mappedLabel} - + {tv.rowLabel} {rowNo}/{total} - +
diff --git a/src/components/Properties/VariableInsertPalette.tsx b/src/components/Properties/VariableInsertPalette.tsx index 9b747364..69c44361 100644 --- a/src/components/Properties/VariableInsertPalette.tsx +++ b/src/components/Properties/VariableInsertPalette.tsx @@ -44,8 +44,8 @@ export function VariableInsertPalette({ const variables = useLabelStore((s) => s.variables); const showZpl = useLabelStore((s) => s.showZplCommands); const addVariable = useLabelStore((s) => s.addVariable); - const csvDataset = useLabelStore((s) => s.csvDataset); - const csvMapping = useLabelStore((s) => s.csvMapping); + const dataset = useLabelStore((s) => s.dataset); + const columnMapping = useLabelStore((s) => s.columnMapping); const secondaryOffset = useLabelStore((s) => s.label.secondaryClockOffset); const tertiaryOffset = useLabelStore((s) => s.label.tertiaryClockOffset); const setLabelConfig = useLabelStore((s) => s.setLabelConfig); @@ -71,8 +71,10 @@ export function VariableInsertPalette({ channel === 1 ? t.app.clockChannelPrimary : channel === 2 ? t.app.clockChannelSecondary : t.app.clockChannelTertiary; const previewFor = (v: Variable): { text: string; cls: string } => { - if (getVariableSource(v, csvDataset, csvMapping) === "csv") { - return { text: `${csvMapping?.bindings[v.id]} · CSV`, cls: "text-accent" }; + if (getVariableSource(v, dataset, columnMapping) === "bound") { + const tag = + dataset?.source.kind === "db" ? "DB" : dataset?.source.kind === "excel" ? "Excel" : "CSV"; + return { text: `${columnMapping?.bindings[v.id]} · ${tag}`, cls: "text-accent" }; } return { text: v.defaultValue ? `"${v.defaultValue}"` : "", cls: "text-muted" }; }; diff --git a/src/components/Variables/ExcelSheetModal.tsx b/src/components/Variables/ExcelSheetModal.tsx new file mode 100644 index 00000000..40d96ac1 --- /dev/null +++ b/src/components/Variables/ExcelSheetModal.tsx @@ -0,0 +1,82 @@ +import { useState } from 'react'; +import { ArrowUpTrayIcon, XMarkIcon } from '@heroicons/react/16/solid'; +import { useT } from '../../hooks/useT'; +import { DialogShell } from '../ui/DialogShell'; +import { Select } from '../ui/Select'; +import type { PendingExcelImport } from '../../hooks/useExcelImportActions'; + +interface Props { + pending: PendingExcelImport; + onLoad: (sheet: string) => Promise; + onCancel: () => void; +} + +/** Worksheet picker between file pick and dataset commit. Always shown so a + * dataset replace takes a deliberate Load click (parity with the CSV + * confirm and the DB modal). */ +export function ExcelSheetModal({ pending, onLoad, onCancel }: Props) { + const tv = useT().variables; + const [sheet, setSheet] = useState(() => pending.sheets[0] ?? ''); + const [loading, setLoading] = useState(false); + + const handleLoad = () => { + if (sheet === '') return; + setLoading(true); + void onLoad(sheet).then((ok) => { + if (!ok) setLoading(false); + }); + }; + + return ( + +
+ + {pending.filename} + + +
+ +
+ + + value={sheet} + onChange={setSheet} + groups={[{ options: pending.sheets.map((s) => ({ value: s, label: s })) }]} + /> +
+ +
+ + +
+
+ ); +} diff --git a/src/components/Variables/VariableMappingModal.tsx b/src/components/Variables/VariableMappingModal.tsx index 7b42f7bb..a71fb605 100644 --- a/src/components/Variables/VariableMappingModal.tsx +++ b/src/components/Variables/VariableMappingModal.tsx @@ -5,15 +5,17 @@ import { useT } from '../../hooks/useT'; import { nextDefaultVariableName, nextFreeFnNumber, - suggestCsvMapping, + suggestColumnMapping, isValidVariableName, - type CsvMapping, + isMappingCompatibleWith, + dbExcelParseOptions, + type ColumnMapping, type CsvParseOptionsPersisted, type Variable, } from '@zplab/core/types/Variable'; +import type { DatasetInput } from '@zplab/core/types/DataSource'; import { decodeImportedText, - getImportedText, parseCsvText, } from '../../lib/csvImport'; import { DialogShell } from '../ui/DialogShell'; @@ -53,9 +55,13 @@ export function VariableMappingModal({ onClose, onImportCsv }: Props) { const t = useT(); const tv = t.variables; const variables = useLabelStore((s) => s.variables); - const csvMapping = useLabelStore((s) => s.csvMapping); - const csvDataset = useLabelStore((s) => s.csvDataset); + const columnMapping = useLabelStore((s) => s.columnMapping); + const dataset = useLabelStore((s) => s.dataset); const applyMappingDraft = useLabelStore((s) => s.applyMappingDraft); + // A db dataset is already tabular: no raw-text cache, no re-parse, no CSV + // options; the draft binds directly against the fetched headers/rows. + const csvSource = dataset === null || dataset.source.kind === 'csv'; + const csvMeta = dataset !== null && dataset.source.kind === 'csv' ? dataset.source : null; // Draft state, initialised once at modal-open. The init-from-prop // pattern is the React-blessed way to seed local state from props @@ -75,26 +81,26 @@ export function VariableMappingModal({ onClose, onImportCsv }: Props) { // last Apply), then fall back to the dataset's source metadata // (the values active at import time), then to library defaults. delimiter: - csvMapping?.parseOptions?.delimiter ?? - csvDataset?.source.delimiter ?? + columnMapping?.parseOptions?.delimiter ?? + csvMeta?.delimiter ?? '', - hasHeaderRow: csvMapping?.parseOptions?.hasHeaderRow ?? true, - skipRows: csvMapping?.parseOptions?.skipRows ?? 0, + hasHeaderRow: columnMapping?.parseOptions?.hasHeaderRow ?? true, + skipRows: columnMapping?.parseOptions?.skipRows ?? 0, encoding: - csvMapping?.parseOptions?.encoding ?? - csvDataset?.source.encoding ?? + columnMapping?.parseOptions?.encoding ?? + csvMeta?.encoding ?? 'utf-8', })); - // Re-decode the cached bytes whenever encoding changes. For UTF-8 - // (the default) skip the roundtrip and use the already-decoded text - // from import time. + // Always re-decode the cached raw bytes for the chosen encoding, including + // utf-8: reusing the import-time text would keep a prior wrong-encoding + // decode, so switching back to utf-8 couldn't rescue a mis-decoded file. const rawText = useMemo(() => { - if (draftOptions.encoding === 'utf-8') return getImportedText(); + if (!csvSource) return null; return decodeImportedText(draftOptions.encoding); - }, [draftOptions.encoding]); + }, [csvSource, draftOptions.encoding]); const [draftRow, setDraftRow] = useState( - csvDataset?.activeRowIndex ?? 0, + dataset?.activeRowIndex ?? 0, ); const [addError, setAddError] = useState(null); @@ -108,19 +114,19 @@ export function VariableMappingModal({ onClose, onImportCsv }: Props) { hasHeaderRow: draftOptions.hasHeaderRow, skipRows: draftOptions.skipRows, encoding: draftOptions.encoding, - filename: csvDataset?.source.filename, + filename: csvMeta?.filename, }); - }, [rawText, draftOptions, csvDataset?.source.filename]); + }, [rawText, draftOptions, csvMeta?.filename]); // Memoise so the useEffect deps below stay reference-stable across // renders that didn't change the underlying parse. const virtualHeaders = useMemo( - () => (draftParse?.ok ? draftParse.value.headers : csvDataset?.headers ?? []), - [draftParse, csvDataset?.headers], + () => (draftParse?.ok ? draftParse.value.headers : dataset?.headers ?? []), + [draftParse, dataset?.headers], ); const virtualRows = useMemo( - () => (draftParse?.ok ? draftParse.value.rows : csvDataset?.rows ?? []), - [draftParse, csvDataset?.rows], + () => (draftParse?.ok ? draftParse.value.rows : dataset?.rows ?? []), + [draftParse, dataset?.rows], ); // Bindings draft. Seeded from existing mapping (only entries whose @@ -128,7 +134,12 @@ export function VariableMappingModal({ onClose, onImportCsv }: Props) { // the rest. Re-derived when virtualHeaders change so newly-vanished // headers drop out and newly-appeared ones can be auto-suggested. const [draftBindings, setDraftBindings] = useState>( - () => buildInitialBindings(csvMapping, draftVariables, virtualHeaders), + () => buildInitialBindings(columnMapping, draftVariables, virtualHeaders), + ); + // Variables the user explicitly set to (unmapped): auto-suggest must not + // re-attach a column they just deliberately removed. + const [explicitlyUnmapped, setExplicitlyUnmapped] = useState>( + () => new Set(), ); useEffect(() => { setDraftBindings((prev) => { @@ -139,22 +150,20 @@ export function VariableMappingModal({ onClose, onImportCsv }: Props) { if (headerSet.has(header)) filtered[varId] = header; else changed = true; } - // Auto-suggest only for variables that existed at modal-open - // and don't yet have a binding. Inline-added drafts stay - // unbound so the user isn't surprised by a header silently - // attaching to a freshly added row whose default name - // happens to fuzzy-match a column. + // Inline-added drafts and explicitly-unmapped rows are excluded from + // auto-suggest, so a freshly added row's default name can't silently + // attach to a fuzzy-matching header. const unboundVars = draftVariables.filter( - (v) => initialVariableIds.has(v.id) && !(v.id in filtered), + (v) => initialVariableIds.has(v.id) && !(v.id in filtered) && !explicitlyUnmapped.has(v.id), ); const usedHeaders = new Set(Object.values(filtered)); const freeHeaders = virtualHeaders.filter((h) => !usedHeaders.has(h)); - const suggested = suggestCsvMapping(unboundVars, freeHeaders); + const suggested = suggestColumnMapping(unboundVars, freeHeaders); const merged = { ...filtered, ...suggested }; if (!changed && Object.keys(suggested).length === 0) return prev; return merged; }); - }, [virtualHeaders, draftVariables, initialVariableIds]); + }, [virtualHeaders, draftVariables, initialVariableIds, explicitlyUnmapped]); // Clamp active-row to virtual rows length (option-change may have // shrunk the dataset). @@ -198,8 +207,8 @@ export function VariableMappingModal({ onClose, onImportCsv }: Props) { }, [draftVariables, tv.csvNameEmpty, tv.csvNameDuplicate, tv.nameInvalid]); const hasNameError = Object.keys(nameErrors).length > 0; - if (!rawText || !csvDataset) { - // Defensive: trigger paths gate on csvDataset, but if the cache is + if (!dataset || (csvSource && !rawText)) { + // Defensive: trigger paths gate on dataset, but if the cache is // empty (e.g. user reloaded the page mid-session) show a friendly // close-only shell. return ( @@ -232,6 +241,13 @@ export function VariableMappingModal({ onClose, onImportCsv }: Props) { const handleChangeBinding = (variableId: string) => (value: string) => { + // Track the explicit (unmapped) so the auto-suggest effect leaves it be. + setExplicitlyUnmapped((prev) => { + const next = new Set(prev); + if (value === '') next.add(variableId); + else next.delete(variableId); + return next; + }); setDraftBindings((prev) => { if (value === '') { if (!(variableId in prev)) return prev; @@ -281,24 +297,31 @@ export function VariableMappingModal({ onClose, onImportCsv }: Props) { }; const handleConfirm = () => { - if (!draftParse?.ok) return; - const parse = draftParse.value; + // CSV commits the freshly-parsed rows; db/excel commit the already-loaded + // dataset. dbExcelParseOptions keeps the carried options safe for re-import. + let ds: DatasetInput; + let parseOptions: CsvParseOptionsPersisted | undefined; + if (csvSource) { + if (!draftParse?.ok) return; + ds = draftParse.value; + parseOptions = persistableParseOptions(draftOptions); + } else { + ds = dataset; + parseOptions = dbExcelParseOptions(columnMapping?.parseOptions); + } applyMappingDraft({ variables: draftVariables, - dataset: parse, - mapping: { - bindings: draftBindings, - headerSnapshot: parse.headers, - parseOptions: persistableParseOptions(draftOptions), - }, + dataset: ds, + mapping: { bindings: draftBindings, headerSnapshot: ds.headers, parseOptions }, activeRowIndex: draftRow, }); onClose(); }; + // Warn only when the mapping actually stops fitting, not on a pure column + // reorder (name-based mappings are order-independent, per isMappingCompatibleWith). const showMismatchWarning = - csvMapping !== null && - !arraysShallowEqual(csvMapping.headerSnapshot, virtualHeaders); + columnMapping !== null && !isMappingCompatibleWith(columnMapping, virtualHeaders); const allSlotsTaken = nextFreeFnNumber(draftVariables.map((v) => v.fnNumber)) === null; @@ -524,16 +547,18 @@ export function VariableMappingModal({ onClose, onImportCsv }: Props) { )} - - - + {csvSource && ( + + + + )}
@@ -545,7 +570,7 @@ export function VariableMappingModal({ onClose, onImportCsv }: Props) {
)} - {!csvDataset && csvMapping && Object.keys(csvMapping.bindings).length > 0 && ( + {canReconnect && dataSourceRef && ( + /* Design carries a database link but no rows in this session; + one click re-fetches (or falls back to the connect dialog when + the saved profile is gone on this machine). */ +
+
+ + + {tv.dbReconnectTitle} + +
+

+ {dbRefDisplayName(dataSourceRef)} +

+ +
+ )} + + {/* Web can't reconnect (Rust connector), so a db-linked design falls + back to the generic saved-mapping hint there. */} + {!dataset && !canReconnect && columnMapping && Object.keys(columnMapping.bindings).length > 0 && ( /* Mapping persisted (design.json or localStorage) but no CSV data in this session; reload, Discard CSV, or opening a saved design. Surface it so the saved bindings don't look @@ -157,7 +213,7 @@ export function VariablesPanel() {