diff --git a/.github/workflows/storage-finder-sync.yml b/.github/workflows/storage-finder-sync.yml new file mode 100644 index 0000000000..d90ad0df02 --- /dev/null +++ b/.github/workflows/storage-finder-sync.yml @@ -0,0 +1,34 @@ +name: Sync Storage Finder Data + +on: + schedule: + - cron: "0 0 * * 6" + workflow_dispatch: + +jobs: + sync: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Bun + uses: oven-sh/setup-bun@v2 + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Generate storage finder data from sheet + run: bun scripts/storage-finder-data-generator/generate.ts --output src/data/storage-finder + + - name: Lint and format + run: | + bun lint --fix + bun format + + - name: Commit changes + uses: stefanzweifel/git-auto-commit-action@v5 + with: + commit_message: "chore: sync storage finder data from sheet" diff --git a/docusaurus.config.ts b/docusaurus.config.ts index c3cd9f0fb4..1f89312603 100644 --- a/docusaurus.config.ts +++ b/docusaurus.config.ts @@ -167,8 +167,7 @@ const config: Config = { items: [ { label: "Feedback", - to: - "https://docs.google.com/forms/d/e/1FAIpQLSeHnmkPdR_IvWnT6a7U_V3RpfmQrpS8hjxI11FNnsZMlrBa4g/viewform", + to: "https://docs.google.com/forms/d/e/1FAIpQLSeHnmkPdR_IvWnT6a7U_V3RpfmQrpS8hjxI11FNnsZMlrBa4g/viewform", }, { label: "Announcements", diff --git a/eslint.config.mjs b/eslint.config.mjs index 9bf8c9b467..c9665edb0c 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -66,6 +66,14 @@ const eslintConfig = tseslint.config( reactPlugin.configs.flat["jsx-runtime"], jsxA11y.flatConfigs.recommended, + { + settings: { + react: { + version: "detect", + }, + }, + }, + { rules: { "@typescript-eslint/consistent-type-imports": [ diff --git a/package.json b/package.json index 77f25844ae..78ea317a9e 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "@docusaurus/types": "^3.9.2", "@eslint/compat": "^1.4.1", "@types/react": "^19.2.2", + "csv-parse": "^6.1.0", "eslint": "^9.39.0", "eslint-config-prettier": "^10.1.8", "eslint-import-resolver-typescript": "^4.4.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 174c9a30c3..ce745f1f0d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -72,6 +72,9 @@ importers: '@types/react': specifier: ^19.2.2 version: 19.2.2 + csv-parse: + specifier: ^6.1.0 + version: 6.1.0 eslint: specifier: ^9.39.0 version: 9.39.0(jiti@1.21.7) @@ -3591,6 +3594,9 @@ packages: csstype@3.1.3: resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} + csv-parse@6.1.0: + resolution: {integrity: sha512-CEE+jwpgLn+MmtCpVcPtiCZpVtB6Z2OKPTr34pycYYoL7sxdOkXDdQ4lRiw6ioC0q6BLqhc6cKweCVvral8yhw==} + currently-unhandled@0.4.1: resolution: {integrity: sha512-/fITjgjGU50vjQ4FH6eUoYu+iUoUKIXws2hL15JJpIR+BbTxaXQsMuuyjtNh2WqsSBS5nsaZHFsFecyw5CCAng==} engines: {node: '>=0.10.0'} @@ -13082,6 +13088,8 @@ snapshots: csstype@3.1.3: {} + csv-parse@6.1.0: {} + currently-unhandled@0.4.1: dependencies: array-find-index: 1.0.2 @@ -16676,7 +16684,7 @@ snapshots: dependencies: htmlparser2: 3.10.1 postcss: 7.0.39 - postcss-syntax: 0.36.2(postcss-html@0.36.0)(postcss-jsx@0.36.4(postcss-syntax@0.36.2(postcss@8.5.6))(postcss@7.0.39))(postcss-less@3.1.4)(postcss-markdown@0.36.0)(postcss-scss@2.1.1)(postcss@7.0.39) + postcss-syntax: 0.36.2(postcss-html@0.36.0(postcss-syntax@0.36.2(postcss@8.5.6))(postcss@7.0.39))(postcss-jsx@0.36.4(postcss-syntax@0.36.2(postcss@8.5.6))(postcss@7.0.39))(postcss-less@3.1.4)(postcss-markdown@0.36.0(postcss-syntax@0.36.2(postcss@8.5.6))(postcss@7.0.39))(postcss-scss@2.1.1)(postcss@7.0.39) postcss-image-set-function@7.0.0(postcss@8.5.6): dependencies: @@ -16688,7 +16696,7 @@ snapshots: dependencies: '@babel/core': 7.28.5 postcss: 7.0.39 - postcss-syntax: 0.36.2(postcss-html@0.36.0)(postcss-jsx@0.36.4(postcss-syntax@0.36.2(postcss@8.5.6))(postcss@7.0.39))(postcss-less@3.1.4)(postcss-markdown@0.36.0)(postcss-scss@2.1.1)(postcss@7.0.39) + postcss-syntax: 0.36.2(postcss-html@0.36.0(postcss-syntax@0.36.2(postcss@8.5.6))(postcss@7.0.39))(postcss-jsx@0.36.4(postcss-syntax@0.36.2(postcss@8.5.6))(postcss@7.0.39))(postcss-less@3.1.4)(postcss-markdown@0.36.0(postcss-syntax@0.36.2(postcss@8.5.6))(postcss@7.0.39))(postcss-scss@2.1.1)(postcss@7.0.39) transitivePeerDependencies: - supports-color @@ -16723,7 +16731,7 @@ snapshots: postcss-markdown@0.36.0(postcss-syntax@0.36.2(postcss@8.5.6))(postcss@7.0.39): dependencies: postcss: 7.0.39 - postcss-syntax: 0.36.2(postcss-html@0.36.0)(postcss-jsx@0.36.4(postcss-syntax@0.36.2(postcss@8.5.6))(postcss@7.0.39))(postcss-less@3.1.4)(postcss-markdown@0.36.0)(postcss-scss@2.1.1)(postcss@7.0.39) + postcss-syntax: 0.36.2(postcss-html@0.36.0(postcss-syntax@0.36.2(postcss@8.5.6))(postcss@7.0.39))(postcss-jsx@0.36.4(postcss-syntax@0.36.2(postcss@8.5.6))(postcss@7.0.39))(postcss-less@3.1.4)(postcss-markdown@0.36.0(postcss-syntax@0.36.2(postcss@8.5.6))(postcss@7.0.39))(postcss-scss@2.1.1)(postcss@7.0.39) remark: 10.0.1 unist-util-find-all-after: 1.0.5 @@ -17033,7 +17041,7 @@ snapshots: postcss-value-parser: 4.2.0 svgo: 3.3.2 - postcss-syntax@0.36.2(postcss-html@0.36.0)(postcss-jsx@0.36.4(postcss-syntax@0.36.2(postcss@8.5.6))(postcss@7.0.39))(postcss-less@3.1.4)(postcss-markdown@0.36.0)(postcss-scss@2.1.1)(postcss@7.0.39): + postcss-syntax@0.36.2(postcss-html@0.36.0(postcss-syntax@0.36.2(postcss@8.5.6))(postcss@7.0.39))(postcss-jsx@0.36.4(postcss-syntax@0.36.2(postcss@8.5.6))(postcss@7.0.39))(postcss-less@3.1.4)(postcss-markdown@0.36.0(postcss-syntax@0.36.2(postcss@8.5.6))(postcss@7.0.39))(postcss-scss@2.1.1)(postcss@7.0.39): dependencies: postcss: 7.0.39 optionalDependencies: @@ -18281,7 +18289,7 @@ snapshots: postcss-sass: 0.3.5 postcss-scss: 2.1.1 postcss-selector-parser: 3.1.2 - postcss-syntax: 0.36.2(postcss-html@0.36.0)(postcss-jsx@0.36.4(postcss-syntax@0.36.2(postcss@8.5.6))(postcss@7.0.39))(postcss-less@3.1.4)(postcss-markdown@0.36.0)(postcss-scss@2.1.1)(postcss@7.0.39) + postcss-syntax: 0.36.2(postcss-html@0.36.0(postcss-syntax@0.36.2(postcss@8.5.6))(postcss@7.0.39))(postcss-jsx@0.36.4(postcss-syntax@0.36.2(postcss@8.5.6))(postcss@7.0.39))(postcss-less@3.1.4)(postcss-markdown@0.36.0(postcss-syntax@0.36.2(postcss@8.5.6))(postcss@7.0.39))(postcss-scss@2.1.1)(postcss@7.0.39) postcss-value-parser: 3.3.1 resolve-from: 4.0.0 signal-exit: 3.0.7 diff --git a/scripts/storage-finder-data-generator/MAINTENANCE.md b/scripts/storage-finder-data-generator/MAINTENANCE.md new file mode 100644 index 0000000000..e579add11c --- /dev/null +++ b/scripts/storage-finder-data-generator/MAINTENANCE.md @@ -0,0 +1,45 @@ +# Storage Finder CSV Importer: Maintainer Guide + +This approach aims to balance user-friendliness (content editors update the Google Sheet) with functionality (developers tune `config.ts` so the JSON stays in sync). + +## What the CLI does + +- Reads a CSV export of the shared Google Sheet (either via `STORAGE_FINDER_SHEET_URL` or `--csv `). +- Applies the questions/choices/matchers defined in `scripts/storage-finder-data-generator/config.ts`. +- Generates `facet-tree.json` and `service-list.json` with slug-based IDs. + +## How to regenerate data + +```sh +bun scripts/storage-finder-data-generator/generate.ts \ + --csv "Datafinder Data - Sheet1.csv" \ + --output src/data/storage-finder +``` + +- Omit `--csv` to download from `STORAGE_FINDER_SHEET_URL`. +- Use `--output` to write elsewhere (defaults to `src/data/storage-finder/generated` in the code). +- If you do not have Bun, install it from [https://bun.sh/](https://bun.sh/) or run with `pnpm dlx tsx scripts/storage-finder-data-generator/generate.ts ...`. Node does not run TypeScript by default; `tsx` provides the TypeScript loader. + +## How to add or change a question + +1. Add a column to the sheet that contains the signals you want to match. +2. In `scripts/storage-finder-data-generator/config.ts`, add a new facet entry to `FACET_CONFIGS`: +- Set `id` (slug), `name`, `controlType` (`radio` or `checkbox`), `column` (sheet column name), and `choices` (labels the app should show). +- Add `matchers`: regex patterns that map cell text to choice IDs. Include `allowMultipleMatches: true` if a radio question legitimately matches more than one choice. +- If no regex matches, `fallback: "all"` keeps the service visible; otherwise supply an explicit array of choice IDs. +3. Regenerate with the CLI and verify in the app. + +## Service fields + +Field definitions live in `scripts/storage-finder-data-generator/config.ts` (`FIELD_DEFINITIONS`). They map sheet columns to service detail rows (Links, Use Case, Limitations, Permission Settings, Eligibility, Synchronous Access, Alumni Access, Backup). Adjust labels or formatters there if the sheet schema changes. + +## Naming and IDs + +- Services are slugged from the `Title` column; duplicates get `-2`, `-3`, etc. +- Facet and choice IDs are slugs defined in `config.ts`; keep them stable to avoid breaking references. + +## Validation rules + +- Radio facets throw if more than one choice matches unless `allowMultipleMatches` is set. +- Blank cells render as “Not Available” in service fields. +- Regexes match against raw cell text; use clear keywords in the sheet for deterministic mapping. diff --git a/scripts/storage-finder-data-generator/config.ts b/scripts/storage-finder-data-generator/config.ts new file mode 100644 index 0000000000..49ef5ecb51 --- /dev/null +++ b/scripts/storage-finder-data-generator/config.ts @@ -0,0 +1,227 @@ +import { toHtmlBlocks, toLinkHtml } from "./html"; +import { type FacetConfig, type ServiceFieldDefinition } from "./types"; + +export const FIELD_DEFINITIONS: ServiceFieldDefinition[] = [ + { + fieldKey: "field_links", + column: "Links", + label: "Links", + weight: 2, + formatter: (value, row) => toLinkHtml(value, row.Title ?? ""), + }, + { + fieldKey: "field_storable_files", + column: "Storable Files", + label: "Storable Files", + weight: 3, + formatter: (value) => toHtmlBlocks(value), + }, + { + fieldKey: "field_use_case", + column: "Use Case", + label: "Use Case", + weight: 4, + formatter: (value) => toHtmlBlocks(value), + }, + { + fieldKey: "field_limitations", + column: "Limitations", + label: "Limitations", + weight: 5, + formatter: (value) => toHtmlBlocks(value), + }, + { + fieldKey: "field_permission_settings", + column: "Permission Settings", + label: "Permission Settings", + weight: 6, + formatter: (value) => toHtmlBlocks(value), + }, + { + fieldKey: "field_eligibility", + column: "Eligibility", + label: "Eligibility", + weight: 7, + formatter: (value) => toHtmlBlocks(value), + }, + { + fieldKey: "field_synchronous_access", + column: "Synchronous Access", + label: "Synchronous Access", + weight: 9, + formatter: (value) => toHtmlBlocks(value), + }, + { + fieldKey: "field_alumni_access", + column: "Alumni Access", + label: "Alumni Access", + weight: 10, + formatter: (value) => toHtmlBlocks(value), + }, + { + fieldKey: "field_backup", + column: "Backup", + label: "Backup", + weight: 11, + formatter: (value) => toHtmlBlocks(value), + }, +]; + +export const FACET_CONFIGS: FacetConfig[] = [ + { + id: "risk-classification", + name: "What is the risk classification of your data?", + description: null, + column: "Storable Files", + controlType: "radio", + allowMultipleMatches: true, + choices: [ + { + id: "risk-classification.public-low", + name: "Public / Low Risk", + weight: 0, + }, + { + id: "risk-classification.sensitive-moderate", + name: "Sensitive / Moderate Risk", + weight: 1, + }, + { + id: "risk-classification.confidential-high", + name: "Confidential or Restricted / High Risk", + weight: 2, + }, + { + id: "risk-classification.hipaa", + name: "HIPAA-Regulated", + weight: 3, + }, + ], + matchers: [ + { pattern: /\bhipaa\b/i, choices: ["risk-classification.hipaa"] }, + { + pattern: /\b(confidential|restricted)\b/i, + choices: ["risk-classification.confidential-high"], + }, + { + pattern: /\bhigh\b/i, + choices: [ + "risk-classification.public-low", + "risk-classification.sensitive-moderate", + "risk-classification.confidential-high", + ], + }, + { + pattern: /\bmoderate\b/i, + choices: [ + "risk-classification.public-low", + "risk-classification.sensitive-moderate", + ], + }, + { + pattern: /\blow\b/i, + choices: ["risk-classification.public-low"], + }, + ], + fallback: "all", + }, + { + id: "affiliation", + name: "What is your University affiliation?", + description: null, + column: "Eligibility", + controlType: "radio", + allowMultipleMatches: true, + choices: [ + { id: "affiliation.faculty", name: "Faculty or PI", weight: 0 }, + { id: "affiliation.staff", name: "University staff", weight: 1 }, + { id: "affiliation.student", name: "Student", weight: 2 }, + ], + matchers: [ + { pattern: /\bfaculty\b/i, choices: ["affiliation.faculty"] }, + { + pattern: /\bprincipal investigator\b/i, + choices: ["affiliation.faculty"], + }, + { pattern: /\bstaff\b/i, choices: ["affiliation.staff"] }, + { pattern: /\bstudent\b/i, choices: ["affiliation.student"] }, + ], + fallback: "all", + }, + { + id: "access-needs", + name: "Who needs access to your data?", + description: null, + column: "Permission Settings", + controlType: "checkbox", + choices: [ + { id: "access-needs.individual", name: "No sharing", weight: 0 }, + { id: "access-needs.public", name: "Public access", weight: 1 }, + { + id: "access-needs.shared-link", + name: "Shared link collaborators", + weight: 2, + }, + { + id: "access-needs.netid-collaborators", + name: "Affiliated collaborators with NetIDs", + weight: 3, + }, + { + id: "access-needs.external-collaborators", + name: "Collaborators external to NYU", + weight: 4, + }, + ], + matchers: [ + { pattern: /\bpublic\b/i, choices: ["access-needs.public"] }, + { + pattern: /\bno sharing\b/i, + choices: ["access-needs.individual"], + }, + { + pattern: /\bindividual use\b/i, + choices: ["access-needs.individual"], + }, + { + pattern: /\bshared link\b/i, + choices: ["access-needs.shared-link"], + }, + { + pattern: /\bexternal\b/i, + choices: ["access-needs.external-collaborators"], + }, + { + pattern: /\bnetid\b/i, + choices: ["access-needs.netid-collaborators"], + }, + { + pattern: /\bgroup\b/i, + choices: ["access-needs.netid-collaborators"], + }, + ], + fallback: "all", + }, + { + id: "backup-availability", + name: "Do you need backups, snapshots or replication of your data?", + description: null, + column: "Backup", + controlType: "radio", + choices: [ + { id: "backup-availability.yes", name: "Yes", weight: 0 }, + { id: "backup-availability.no", name: "No", weight: 1 }, + ], + matchers: [ + { + pattern: /\b(yes|available)\b/i, + choices: ["backup-availability.yes"], + }, + { + pattern: /\b(no|not available)\b/i, + choices: ["backup-availability.no"], + }, + ], + fallback: "all", + }, +]; diff --git a/scripts/storage-finder-data-generator/constants.ts b/scripts/storage-finder-data-generator/constants.ts new file mode 100644 index 0000000000..e3a4974bc8 --- /dev/null +++ b/scripts/storage-finder-data-generator/constants.ts @@ -0,0 +1,10 @@ +export const STORAGE_FINDER_ENV_URL_KEY = "STORAGE_FINDER_SHEET_URL"; + +export const DEFAULT_STORAGE_FINDER_SHEET_URL = + "https://docs.google.com/spreadsheets/d/12vxBpVUpWTrPmZ-3e30IbyyoAG9qQ6ULpMq5O8okPtA/export?format=csv&gid=1073279644"; + +export const OUTPUT_DIRECTORY = "src/data/storage-finder/generated"; + +export const SERVICE_LIST_FILENAME = "service-list.json"; + +export const FACET_TREE_FILENAME = "facet-tree.json"; diff --git a/scripts/storage-finder-data-generator/generate.ts b/scripts/storage-finder-data-generator/generate.ts new file mode 100644 index 0000000000..ea2f772a1c --- /dev/null +++ b/scripts/storage-finder-data-generator/generate.ts @@ -0,0 +1,351 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; + +import { parse } from "csv-parse/sync"; + +import { FACET_CONFIGS, FIELD_DEFINITIONS } from "./config"; +import { + DEFAULT_STORAGE_FINDER_SHEET_URL, + FACET_TREE_FILENAME, + OUTPUT_DIRECTORY, + SERVICE_LIST_FILENAME, + STORAGE_FINDER_ENV_URL_KEY, +} from "./constants"; +import { toHtmlBlocks } from "./html"; +import { + type CsvRow, + type FacetConfig, + type FacetTreeChoice, + type FacetTreeQuestion, + type ServiceField, + type ServiceRecord, +} from "./types"; + +interface CliOptions { + csvPath?: string; + outputDir?: string; + pretty: boolean; + silent: boolean; + showHelp: boolean; +} + +interface Logger { + log(message: string): void; + warn(message: string): void; +} + +function parseArgs(argv: string[]): CliOptions { + const options: CliOptions = { + pretty: true, + silent: false, + showHelp: false, + }; + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + if (argument.includes("=")) { + const [flag, explicitValue] = argument.split("=", 2); + switch (flag) { + case "--csv": { + options.csvPath = explicitValue; + break; + } + case "--output": { + options.outputDir = explicitValue; + break; + } + default: { + throw new Error( + `Unknown argument "${argument}". Use --help for usage.`, + ); + } + } + continue; + } + switch (argument) { + case "--csv": { + options.csvPath = argv[index + 1]; + index += 1; + break; + } + case "--output": { + options.outputDir = argv[index + 1]; + index += 1; + break; + } + case "--no-pretty": { + options.pretty = false; + break; + } + case "--silent": { + options.silent = true; + break; + } + case "--help": { + options.showHelp = true; + break; + } + default: { + throw new Error( + `Unknown argument "${argument}". Use --help for usage.`, + ); + } + } + } + return options; +} + +function printHelp(): void { + const lines = [ + "Usage: bun scripts/storage-finder-data-generator/generate.ts [options]", + "", + "--csv Use a local CSV file instead of downloading", + "--output Custom output directory (defaults to src/data/storage-finder/generated)", + "--no-pretty Write minified JSON", + "--silent Suppress informational logs", + "--help Show this message", + "", + `Environment: ${STORAGE_FINDER_ENV_URL_KEY} overrides the CSV download URL.`, + ]; + console.log(lines.join("\n")); +} + +function createLogger(silent: boolean): Logger { + if (silent) { + const noop = (..._args: unknown[]) => { + void _args; + }; + return { + log: noop, + warn: noop, + }; + } + return { + log(message: string) { + console.log(message); + }, + warn(message: string) { + console.warn(message); + }, + }; +} + +async function loadCsvSource( + csvPath: string | undefined, + logger: Logger, +): Promise { + if (csvPath) { + logger.log(`Reading CSV from ${csvPath}`); + return readFile(csvPath, "utf8"); + } + const url = + process.env[STORAGE_FINDER_ENV_URL_KEY] ?? DEFAULT_STORAGE_FINDER_SHEET_URL; + logger.log(`Downloading CSV from ${url}`); + const response = await fetch(url); + if (!response.ok) { + throw new Error( + `Failed to download CSV. HTTP ${response.status} ${response.statusText}`, + ); + } + return response.text(); +} + +function parseCsv(csvContent: string): CsvRow[] { + const parsed = parse(csvContent, { + columns: true, + skip_empty_lines: true, + trim: true, + }); + return parsed.map((row) => mapUndefinedToEmptyStrings(row)); +} + +function mapUndefinedToEmptyStrings(row: CsvRow): CsvRow { + const mapped: CsvRow = {}; + for (const key of Object.keys(row)) { + const value = row[key]; + mapped[key] = value === undefined || value === null ? "" : String(value); + } + return mapped; +} + +function buildServiceRecords(rows: CsvRow[], logger: Logger): ServiceRecord[] { + const services: ServiceRecord[] = []; + const seenIds = new Map(); + for (const [index, row] of rows.entries()) { + const title = (row.Title ?? "").trim(); + if (title.length === 0) { + logger.warn(`Skipping row ${index + 1} because Title is missing.`); + continue; + } + const baseId = slugify(title); + const serviceId = resolveUniqueId(baseId, seenIds); + services.push(createServiceRecord(serviceId, title, row)); + } + return services; +} + +function createServiceRecord( + serviceId: string, + title: string, + row: CsvRow, +): ServiceRecord { + const fieldData = buildFieldData(row); + const facetMatches = collectFacetMatches(row, title); + return { + id: serviceId, + title, + facet_matches: facetMatches, + summary: null, + field_data: fieldData, + }; +} + +function buildFieldData(row: CsvRow): Record { + const entries: [string, ServiceField][] = FIELD_DEFINITIONS.map( + (definition) => { + const rawValue = row[definition.column] ?? ""; + const value = + definition.formatter?.(rawValue, row) ?? toHtmlBlocks(rawValue); + return [ + definition.fieldKey, + { + value, + label: definition.label, + weight: definition.weight, + }, + ]; + }, + ); + return Object.fromEntries(entries); +} + +function collectFacetMatches(row: CsvRow, serviceTitle: string): string[] { + const identifiers = new Set(); + for (const config of FACET_CONFIGS) { + const value = row[config.column] ?? ""; + const matches = matchFacetValue(value, config); + if ( + config.controlType === "radio" && + !config.allowMultipleMatches && + matches.length > 1 + ) { + throw new Error( + `Service "${serviceTitle}" matched multiple options for radio facet "${config.name}": ${matches.join(", ")}`, + ); + } + for (const match of matches) { + identifiers.add(match); + } + if (config.alwaysInclude) { + for (const extra of config.alwaysInclude) { + identifiers.add(extra); + } + } + } + return [...identifiers]; +} + +function matchFacetValue(value: string, config: FacetConfig): string[] { + const matches = new Set(); + for (const matcher of config.matchers) { + if (matcher.pattern.test(value)) { + for (const choice of matcher.choices) { + matches.add(choice); + } + } + } + if (matches.size > 0) { + return [...matches]; + } + if (config.fallback === "all") { + return config.choices.map((choice) => choice.id); + } + return [...config.fallback]; +} + +function resolveUniqueId(baseId: string, seen: Map): string { + if (!seen.has(baseId)) { + seen.set(baseId, 1); + return baseId; + } + const current = seen.get(baseId) ?? 1; + const next = current + 1; + seen.set(baseId, next); + return `${baseId}-${next}`; +} + +function buildFacetTree(): FacetTreeQuestion[] { + return FACET_CONFIGS.map((config, index) => ({ + id: config.id, + name: config.name, + control_type: config.controlType, + parent: "0", + weight: String(index * 2), + selected: false, + description: config.description ?? null, + choices: buildFacetChoices(config), + })); +} + +function buildFacetChoices(config: FacetConfig): FacetTreeChoice[] { + return config.choices.map((choice) => ({ + id: choice.id, + name: choice.name, + control_type: config.controlType, + parent: config.id, + weight: String(choice.weight), + selected: false, + description: choice.description ?? null, + })); +} + +async function writeJson( + path: string, + data: unknown, + pretty: boolean, +): Promise { + const spacing = pretty ? 2 : 0; + const content = JSON.stringify(data, null, spacing); + await writeFile(path, content + "\n", "utf8"); +} + +function slugify(value: string): string { + const normalized = value.toLowerCase().replaceAll(/[^a-z0-9]+/g, "-"); + const trimmed = normalized.replaceAll(/^-+|-+$/g, ""); + if (trimmed.length === 0) { + return "service"; + } + return trimmed; +} + +async function main(): Promise { + const options = parseArgs(process.argv.slice(2)); + if (options.showHelp) { + printHelp(); + return; + } + const logger = createLogger(options.silent); + const csvContent = await loadCsvSource(options.csvPath, logger); + const rows = parseCsv(csvContent); + if (rows.length === 0) { + throw new Error("CSV file did not contain any data rows."); + } + const services = buildServiceRecords(rows, logger); + const facetTree = buildFacetTree(); + const outputDirectory = options.outputDir ?? OUTPUT_DIRECTORY; + await mkdir(outputDirectory, { recursive: true }); + const serviceOutputPath = `${outputDirectory}/${SERVICE_LIST_FILENAME}`; + const facetOutputPath = `${outputDirectory}/${FACET_TREE_FILENAME}`; + await writeJson(serviceOutputPath, services, options.pretty); + await writeJson(facetOutputPath, facetTree, options.pretty); + logger.log(`Wrote ${services.length} services to ${serviceOutputPath}`); + logger.log(`Wrote ${facetTree.length} facets to ${facetOutputPath}`); +} + +// eslint-disable-next-line unicorn/prefer-top-level-await +void (async () => { + try { + await main(); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + } +})(); diff --git a/scripts/storage-finder-data-generator/html.ts b/scripts/storage-finder-data-generator/html.ts new file mode 100644 index 0000000000..286e9edfc8 --- /dev/null +++ b/scripts/storage-finder-data-generator/html.ts @@ -0,0 +1,47 @@ +/** Converts text into HTML paragraphs with preserved line breaks. */ +export function toHtmlBlocks(value: string): string { + const normalized = normalizeValue(value); + if (normalized.length === 0) { + return "

Not Available

"; + } + const paragraphs = normalized.split(/\n{2,}/).map((paragraph) => + paragraph + .split(/\n+/) + .map((line) => escapeHtml(line)) + .join("
"), + ); + return paragraphs.map((paragraph) => `

${paragraph}

`).join(""); +} + +/** Converts a URL and label into a single HTML link paragraph. */ +export function toLinkHtml(url: string, label: string): string { + const normalizedUrl = normalizeValue(url); + const normalizedLabel = normalizeValue(label); + if (normalizedUrl.length === 0 && normalizedLabel.length === 0) { + return "

Not Available

"; + } + if (normalizedUrl.length === 0) { + return `

${escapeHtml(normalizedLabel)}

`; + } + const safeUrl = escapeAttribute(normalizedUrl); + const safeLabel = + normalizedLabel.length === 0 ? normalizedUrl : escapeHtml(normalizedLabel); + return `

${safeLabel}

`; +} + +function escapeHtml(input: string): string { + return input + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function escapeAttribute(input: string): string { + return escapeHtml(input).replaceAll("`", "`"); +} + +function normalizeValue(value: string): string { + return value.replaceAll("\r\n", "\n").trim(); +} diff --git a/scripts/storage-finder-data-generator/types.ts b/scripts/storage-finder-data-generator/types.ts new file mode 100644 index 0000000000..8bc64a0ca0 --- /dev/null +++ b/scripts/storage-finder-data-generator/types.ts @@ -0,0 +1,69 @@ +export type CsvRow = Record; + +export interface ServiceFieldDefinition { + fieldKey: string; + column: string; + label: string; + weight: number; + formatter?: (value: string, row: CsvRow) => string; +} + +export interface FacetMatcherDefinition { + pattern: RegExp; + choices: string[]; +} + +export interface FacetChoiceConfig { + id: string; + name: string; + description?: string | null; + weight: number; +} + +export interface FacetConfig { + id: string; + name: string; + description?: string | null; + column: string; + controlType: "radio" | "checkbox"; + choices: FacetChoiceConfig[]; + matchers: FacetMatcherDefinition[]; + fallback: "all" | string[]; + alwaysInclude?: string[]; + allowMultipleMatches?: boolean; +} + +export interface ServiceField { + value: string; + label: string; + weight: number; +} + +export interface ServiceRecord { + id: string; + title: string; + facet_matches: string[]; + summary: null; + field_data: Record; +} + +export interface FacetTreeChoice { + id: string; + name: string; + control_type: "radio" | "checkbox"; + parent: string; + weight: string; + selected: boolean; + description: string | null; +} + +export interface FacetTreeQuestion { + id: string; + name: string; + control_type: "radio" | "checkbox"; + parent: string; + weight: string; + selected: boolean; + description: string | null; + choices: FacetTreeChoice[]; +} diff --git a/src/components/HomepageFeatures/index.tsx b/src/components/HomepageFeatures/index.tsx index fad840de58..c729d161b1 100644 --- a/src/components/HomepageFeatures/index.tsx +++ b/src/components/HomepageFeatures/index.tsx @@ -116,10 +116,9 @@ export default function HomepageFeatures() {
- {FeatureList.map((props, index) => )} + {FeatureList.map((props, index) => ( + + ))}