diff --git a/src/app/components/Editor.tsx b/src/app/components/Editor.tsx index 106bcf48..53d5f34f 100644 --- a/src/app/components/Editor.tsx +++ b/src/app/components/Editor.tsx @@ -22,6 +22,7 @@ import mimeTypes from "../contents/mime-types"; import platforms from "../contents/platforms"; import PublicCode, { defaultItaly, + FIELD_MIN_VERSIONS, IT_COUNTRY_EXTENSION_VERSION, LATEST_VERSION, PublicCodeWithDeprecatedFields, @@ -43,7 +44,7 @@ import { import { collectRemovedKeys, getYaml } from "../lib/utils"; import linter from "../linter"; import publicCodeAdapter from "../publiccode-adapter"; -import { toSemVerObject } from "../semver"; +import { isVersionAtLeast, toSemVerObject } from "../semver"; import { validator } from "../validator"; import EditorAwards from "./EditorAwards"; import EditorBoolean from "./EditorBoolean"; @@ -227,6 +228,15 @@ export default function Editor() { }); const { getValues, handleSubmit, watch, setValue, reset } = methods; + // Show a field only if it exists in the declared publiccode.yml version + // (e.g. `supports` from 0.7.0, `organisation`/`fundedBy` from 0.5.0). + const declaredVersion = watch("publiccodeYmlVersion"); + const isFieldAvailable = (field: keyof typeof FIELD_MIN_VERSIONS) => + isVersionAtLeast( + declaredVersion || LATEST_VERSION, + FIELD_MIN_VERSIONS[field], + ); + const checkPubliccodeYmlVersion = useCallback((publicCode: PublicCode) => { const { publiccodeYmlVersion } = publicCode; setPubliccodeYmlVersion(publiccodeYmlVersion); @@ -613,9 +623,11 @@ export default function Editor() { fieldName="isBasedOn" /> -
- -
+ {isFieldAvailable("fundedBy") && ( +
+ +
+ )} fieldName="roadmap" /> @@ -695,18 +707,20 @@ export default function Editor() { fieldName="logo" /> - - - - - fieldName="organisation.uri" - required - /> - - - fieldName="organisation.name" /> - - + {isFieldAvailable("supports") && } + {isFieldAvailable("organisation") && ( + + + + fieldName="organisation.uri" + required + /> + + + fieldName="organisation.name" /> + + + )} diff --git a/src/app/contents/publiccode.ts b/src/app/contents/publiccode.ts index a333af8b..b1652383 100644 --- a/src/app/contents/publiccode.ts +++ b/src/app/contents/publiccode.ts @@ -7,6 +7,18 @@ import softwareTypes from "./softwareTypes"; export const LATEST_VERSION = "0.7.0"; export const IT_COUNTRY_EXTENSION_VERSION = "1.0"; +// Minimum publiccode.yml version in which each key exists: with an older +// declared version the key is neither shown in the editor nor serialized. +export const FIELD_MIN_VERSIONS = { + supports: "0.7.0", + organisation: "0.5.0", + fundedBy: "0.5.0", +} as const; + +// Since 0.5.0 country section keys (IT:) and ISO 3166-1 alpha-2 codes are +// uppercase (lowercase deprecated); older versions mandated lowercase. +export const UPPERCASE_COUNTRY_MIN_VERSION = "0.5.0"; + // Known aliases for the `supports` field (publiccode.yml v0.7). // The stored value is `alias:`; `text` is the human-readable label. // See https://github.com/italia/publiccode-parser-go (supports_id validator). diff --git a/src/app/country-case.spec.ts b/src/app/country-case.spec.ts new file mode 100644 index 00000000..ee7993ea --- /dev/null +++ b/src/app/country-case.spec.ts @@ -0,0 +1,55 @@ +import YAML from "yaml"; +import { publicCodeDummyObjectFactory } from "./contents/publiccode"; +import { getYaml, parseYaml } from "./lib/utils"; + +describe("country section and country codes case by declared version", () => { + const withCountryData = (publiccodeYmlVersion: string) => ({ + ...publicCodeDummyObjectFactory(), + publiccodeYmlVersion, + intendedAudience: { countries: ["it", "DE"] }, + it: { + countryExtensionVersion: "1.0", + riuso: { codiceIPA: "c_h501" }, + }, + }); + + it("emits uppercase (IT:, ISO codes) from 0.5.0 onwards", () => { + const yaml = getYaml(withCountryData("0.7.0") as never) ?? ""; + const parsed = YAMLparse(yaml); + + expect(parsed.IT).toBeDefined(); + expect(parsed.it).toBeUndefined(); + expect(parsed.intendedAudience.countries).toEqual(["IT", "DE"]); + }); + + it("emits lowercase (it:, ISO codes) for versions before 0.5.0", () => { + const yaml = getYaml(withCountryData("0.4.0") as never) ?? ""; + const parsed = YAMLparse(yaml); + + expect(parsed.it).toBeDefined(); + expect(parsed.IT).toBeUndefined(); + expect(parsed.intendedAudience.countries).toEqual(["it", "de"]); + }); + + it("round-trips an old lowercase file into the internal representation", () => { + const imported = parseYaml(` +publiccodeYmlVersion: "0.4.0" +it: + riuso: + codiceIPA: c_h501 +intendedAudience: + countries: + - it +`); + + expect(imported?.it?.riuso?.codiceIPA).toBe("c_h501"); + // internal representation is always uppercase + expect(imported?.intendedAudience?.countries).toEqual(["IT"]); + }); +}); + +// Parse raw YAML without the internal-representation normalization done by +// parseYaml, to assert on the actual serialized key case. +function YAMLparse(yaml: string) { + return YAML.parse(yaml); +} diff --git a/src/app/lib/utils.ts b/src/app/lib/utils.ts index 9863b6a4..5e0f2253 100644 --- a/src/app/lib/utils.ts +++ b/src/app/lib/utils.ts @@ -1,51 +1,68 @@ import { useEffect, useState } from "react"; import YAML from "yaml"; -import PublicCode from "../contents/publiccode"; +import PublicCode, { + LATEST_VERSION, + UPPERCASE_COUNTRY_MIN_VERSION, +} from "../contents/publiccode"; import linter from "../linter"; +import { isVersionAtLeast } from "../semver"; /** - * Converts country codes in intendedAudience to uppercase (ISO 3166-1 alpha-2 standard) + * Converts country codes in intendedAudience to the case mandated by the + * declared publiccode.yml version (uppercase since 0.5.0, lowercase before) */ -function convertCountriesToUppercase( - intendedAudience?: PublicCode["intendedAudience"] +function convertCountriesCase( + intendedAudience: PublicCode["intendedAudience"], + toUppercase: boolean ): PublicCode["intendedAudience"] { if (!intendedAudience) { return intendedAudience; } + const convertCase = (code: unknown) => + typeof code === "string" + ? toUppercase + ? code.toUpperCase() + : code.toLowerCase() + : code; + const converted = { ...intendedAudience }; if (Array.isArray(converted.countries)) { - converted.countries = converted.countries.map((code) => - typeof code === "string" ? code.toUpperCase() : code - ); + converted.countries = converted.countries.map(convertCase) as string[]; } if (Array.isArray(converted.unsupportedCountries)) { converted.unsupportedCountries = converted.unsupportedCountries.map( - (code) => (typeof code === "string" ? code.toUpperCase() : code) - ); + convertCase + ) as string[]; } return converted; } /** - * Converts the data object to use uppercase country section keys (e.g., "it" -> "IT") - * and uppercase country codes in intendedAudience + * Converts the data object to the country-code case mandated by the declared + * publiccode.yml version: uppercase section keys (e.g., "it" -> "IT") and + * country codes since 0.5.0, lowercase for older versions */ function convertForYamlOutput(data: PublicCode): Record { const linted = linter(data); const converted: Record = { ...linted }; + const toUppercase = isVersionAtLeast( + linted.publiccodeYmlVersion || LATEST_VERSION, + UPPERCASE_COUNTRY_MIN_VERSION + ); - if ("it" in converted && converted.it) { + if ("it" in converted && converted.it && toUppercase) { converted.IT = converted.it; delete converted.it; } if (linted.intendedAudience) { - converted.intendedAudience = convertCountriesToUppercase( - linted.intendedAudience + converted.intendedAudience = convertCountriesCase( + linted.intendedAudience, + toUppercase ); } diff --git a/src/app/linter/index.ts b/src/app/linter/index.ts index 1bff3943..8b8f367c 100644 --- a/src/app/linter/index.ts +++ b/src/app/linter/index.ts @@ -13,7 +13,10 @@ import PublicCode, { defaultPiattaforme, defaultRiuso, defaultSupport, + FIELD_MIN_VERSIONS, + LATEST_VERSION, } from "../contents/publiccode"; +import { isVersionAtLeast } from "../semver"; import { removeEmpty } from "./remove-empty"; function validateCategories(categoriesArray: string[]): string[] { @@ -85,6 +88,12 @@ export default function linter({ !dependency.version && dependency.optional === undefined); + const hasField = (field: keyof typeof FIELD_MIN_VERSIONS) => + isVersionAtLeast( + publiccodeYmlVersion || LATEST_VERSION, + FIELD_MIN_VERSIONS[field], + ); + const sortedPC: PublicCode = { publiccodeYmlVersion, name, @@ -99,17 +108,21 @@ export default function linter({ categories: categories ? (validateCategories(categories) as (typeof categories)[number][]) : undefined, - organisation, - fundedBy: fundedBy - ?.filter((fo) => !isEmptyFundingOrg(fo)) - .map((fo) => sortAs(defaultFundingOrganisation, fo)), + organisation: hasField("organisation") ? organisation : undefined, + fundedBy: hasField("fundedBy") + ? fundedBy + ?.filter((fo) => !isEmptyFundingOrg(fo)) + .map((fo) => sortAs(defaultFundingOrganisation, fo)) + : undefined, usedBy: clone(usedBy), roadmap, developmentStatus, softwareType, - supports: supports - ?.filter((s) => s?.id != null && s.id.trim() !== "") - .map((s) => sortAs(defaultSupport, s)), + supports: hasField("supports") + ? supports + ?.filter((s) => s?.id != null && s.id.trim() !== "") + .map((s) => sortAs(defaultSupport, s)) + : undefined, intendedAudience: intendedAudience ? sortAs(defaultIntendedAudience, intendedAudience) : undefined, diff --git a/src/app/semver.spec.ts b/src/app/semver.spec.ts index 7647490c..0c8a9f4b 100644 --- a/src/app/semver.spec.ts +++ b/src/app/semver.spec.ts @@ -1,4 +1,4 @@ -import { isMinorThanLatest, toSemVerObject } from "./semver"; +import { isMinorThanLatest, isVersionAtLeast, toSemVerObject } from "./semver"; describe("semver test", () => { it("should run", () => { @@ -40,4 +40,15 @@ describe("semver test", () => { expect(actual05).toBeTruthy(); expect(actual07).toBeFalsy(); }); + + it("isVersionAtLeast compares versions component-wise", () => { + expect(isVersionAtLeast("0.7.0", "0.7.0")).toBe(true); + expect(isVersionAtLeast("0.7.1", "0.7.0")).toBe(true); + expect(isVersionAtLeast("1.0.0", "0.7.0")).toBe(true); + expect(isVersionAtLeast("1.0.0", "0.5.0")).toBe(true); + expect(isVersionAtLeast("0.7", "0.7.0")).toBe(true); + expect(isVersionAtLeast("0.5.0", "0.7.0")).toBe(false); + expect(isVersionAtLeast("0.6.9", "0.7.0")).toBe(false); + expect(isVersionAtLeast("0.2", "0.5.0")).toBe(false); + }); }); diff --git a/src/app/semver.ts b/src/app/semver.ts index d35f3f40..a22d4a3c 100644 --- a/src/app/semver.ts +++ b/src/app/semver.ts @@ -41,6 +41,19 @@ export function toSemVerObject(versionString: string) { } satisfies SemVerObject; } +export const isVersionAtLeast = (version: string, min: string) => { + const v = toSemVerObject(version); + const m = toSemVerObject(min); + + if (+v.major !== +m.major) { + return +v.major > +m.major; + } + if (+v.minor !== +m.minor) { + return +v.minor > +m.minor; + } + return +v.patch >= +m.patch; +}; + export const isMinorThanLatest = (semver: SemVerObject) => { const latest = toSemVerObject(LATEST_VERSION); diff --git a/src/app/supports.spec.ts b/src/app/supports.spec.ts index 7bb929d9..34938dea 100644 --- a/src/app/supports.spec.ts +++ b/src/app/supports.spec.ts @@ -58,4 +58,47 @@ supports: expect(linter(allEmpty).supports).toBeUndefined(); }); + + it("drops `supports` when the declared version is older than 0.7.0", () => { + const pc = { + ...publicCodeDummyObjectFactory(), + publiccodeYmlVersion: "0.5.0", + supports: [{ id: "alias:gdpr" }], + organisation: { uri: "https://example.org" }, + fundedBy: [{ name: "ACME" }], + }; + + const linted = linter(pc as never); + + expect(linted.supports).toBeUndefined(); + // organisation and fundedBy exist since 0.5.0, so they must survive + expect(linted.organisation).toEqual({ uri: "https://example.org" }); + expect(linted.fundedBy).toEqual([{ name: "ACME", uri: undefined }]); + }); + + it("drops `organisation` and `fundedBy` when the declared version is older than 0.5.0", () => { + const pc = { + ...publicCodeDummyObjectFactory(), + publiccodeYmlVersion: "0.4.0", + supports: [{ id: "alias:gdpr" }], + organisation: { uri: "https://example.org" }, + fundedBy: [{ name: "ACME" }], + }; + + const linted = linter(pc as never); + + expect(linted.supports).toBeUndefined(); + expect(linted.organisation).toBeUndefined(); + expect(linted.fundedBy).toBeUndefined(); + }); + + it("keeps version-gated fields at the exact minimum version", () => { + const pc = { + ...publicCodeDummyObjectFactory(), + publiccodeYmlVersion: "0.7.0", + supports: [{ id: "alias:gdpr" }], + }; + + expect(linter(pc as never).supports).toEqual([{ id: "alias:gdpr" }]); + }); });