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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,7 @@ node_modules
dist
coverage
package-lock.json

# Skrevet av npm run import:tools. Formatet bestemmes av skriptet, slik at en
# ny import ikke gir formateringsfeil.
backend/src/seed/data
22 changes: 19 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,9 @@ I den ferdige løsningen ligger dataene i en database:

Annonser, bookinger og vurderinger er de brukergenererte dataene som lagres.
Eksempelannonsene seedes i PostgreSQL via `backend/` (samme innhold som mock).
Om vi henter produktdata fra en ekstern kilde, er ikke avklart.
For å teste skalering fyller `npm run seed:large` databasen med 10 000
genererte annonser. Verktøytypene og merkene er ekte og hentet fra Wikidata,
mens selve annonsene genereres. Se [docs/datasett.md](docs/datasett.md).

### Planlagt videre

Expand Down Expand Up @@ -212,6 +214,18 @@ npm run seed # forvent 0 nye annonser andre gang
Forvent **10** rader i `listings`. Seed-data tilsvarer
`src/data/mockListings.ts` (id `listing-001`–`listing-010`).

**7. Stort datasett:**

```bash
npm run seed:large # 10 000 genererte annonser
npm run seed:large -- --count 50000 # annet antall
npm run seed:large -- --replace # erstatt tidligere genererte
```

Forvent **10 010** rader i `listings` etter første kjøring, og «0 nye» om du
kjører den på nytt. Tar under ett sekund for 10 000 annonser. Datakilde,
fordelinger og målinger står i [docs/datasett.md](docs/datasett.md).

**Bilder og deploy:** Seed lagrer `image_url` under `/project2/assets/listings/`.
Det gjelder VM/Apache senere — se [deploy/apache/README.md](deploy/apache/README.md).
Lokal frontend bruker fortsatt mock og importerte SVG-er.
Expand Down Expand Up @@ -296,7 +310,8 @@ Vitest laster bare `backend/.env.test` (ikke `.env`). Hvis
| `npm run format:check` | Sjekker formatering uten å endre filer |

Backend (`cd backend`): `npm run dev`, `npm run start`, `npm run migrate`,
`npm run seed`, `npm run typecheck`, `npm test`.
`npm run seed`, `npm run seed:large`, `npm run import:tools`,
`npm run typecheck`, `npm test`.

ESLint finner feil i koden, mens Prettier bestemmer formateringen.
`eslint-config-prettier` ligger sist i `eslint.config.js` og slår av
Expand All @@ -317,7 +332,7 @@ backend/
src/db/ Tilkoblingspool og repositories
src/graphql/ GraphQL-schema, resolvers og API-tester
src/scripts/ Seed og andre engangsskript
src/seed/ Seed-data for eksempelannonser
src/seed/ Seed-data, verktøymodeller fra Wikidata og generator for stort datasett
src/server.ts Apollo Server (GraphQL)
src/
types/ Felles datatyper, for eksempel Listing
Expand Down Expand Up @@ -427,6 +442,7 @@ utlån, korte og lange titler, tomt tilbehør og annonser uten bilde. Se
| [docs/prosjektstandard.md](docs/prosjektstandard.md) | Branches, commit-format, pull requests og krav før merge |
| [docs/design.md](docs/design.md) | Farger, typografi, kontrast og felles komponenter |
| [docs/testing.md](docs/testing.md) | Tester, kontroller, resultater og det som gjenstår |
| [docs/datasett.md](docs/datasett.md) | Datakilde, generert datasett og målinger |
| [docs/vm-backend.md](docs/vm-backend.md) | PostgreSQL og backend på NTNU-VM |
| [deploy/apache/README.md](deploy/apache/README.md) | Apache-mal for /project2/ og API-proxy |
| [docs/ki-bruk.md](docs/ki-bruk.md) | Hvordan vi bruker og kontrollerer KI |
Expand Down
24 changes: 24 additions & 0 deletions backend/migrations/1791978982612_add-listings-created-at.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import type { ColumnDefinitions, MigrationBuilder } from 'node-pg-migrate';

export const shorthands: ColumnDefinitions | undefined = undefined;

/**
* Legger til når annonsen ble opprettet. Trengs for å sortere på «nyeste» og
* for å spre genererte annonser utover tid.
*
* Eksisterende rader får tidspunktet migrasjonen kjøres. Nye rader får
* tidspunktet de settes inn, med mindre seed-skriptet oppgir et eget.
*/
export async function up(pgm: MigrationBuilder): Promise<void> {
pgm.addColumn('listings', {
created_at: {
type: 'timestamptz',
notNull: true,
default: pgm.func('current_timestamp'),
},
});
}

export async function down(pgm: MigrationBuilder): Promise<void> {
pgm.dropColumn('listings', 'created_at');
}
2 changes: 2 additions & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
"start": "tsx --env-file=.env src/server.ts",
"migrate": "node-pg-migrate up --envPath .env",
"seed": "tsx --env-file=.env src/scripts/seed.ts",
"seed:large": "tsx --env-file=.env src/scripts/seedLarge.ts",
"import:tools": "tsx src/scripts/importToolModels.ts",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest"
Expand Down
180 changes: 180 additions & 0 deletions backend/src/scripts/importToolModels.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
/**
* Henter verktøytyper og merker fra Wikidata og lagrer dem i
* src/seed/data/toolModels.json.
*
* Resultatet committes, slik at seeding ikke er avhengig av at Wikidata er
* tilgjengelig. Kjør skriptet på nytt bare når listene i toolSources.ts endres:
*
* npm run import:tools
*
* Wikidata-data er lisensiert CC0, så vi kan bruke og lagre dem fritt.
*/

import { mkdir, writeFile } from 'node:fs/promises';
import { dirname } from 'node:path';

import {
toolModelsPath as outputPath,
type Brand,
type ToolModelsFile,
type ToolType,
} from '../seed/toolModels.js';
import { brandSources, toolTypeSources } from '../seed/toolSources.js';

const SPARQL_ENDPOINT = 'https://query.wikidata.org/sparql';
// Wikimedia krever en User-Agent som sier hvem som sender forespørselen.
const USER_AGENT =
'T36-Project2-verktoyutlan/1.0 (NTNU IT2810 studentprosjekt; https://git.ntnu.no/IT2810-H26/T36-Project-2)';

interface WikidataEntity {
nbLabel: string | null;
enLabel: string | null;
}

/** Henter norsk og engelsk navn for alle ID-ene i én spørring. */
async function fetchEntities(
ids: string[],
): Promise<Map<string, WikidataEntity>> {
const query = `
SELECT ?item ?nbLabel ?enLabel WHERE {
VALUES ?item { ${ids.map((id) => `wd:${id}`).join(' ')} }
OPTIONAL { ?item rdfs:label ?nbLabel FILTER(LANG(?nbLabel) = "nb") }
OPTIONAL { ?item rdfs:label ?enLabel FILTER(LANG(?enLabel) = "en") }
}`;

const response = await fetch(
`${SPARQL_ENDPOINT}?query=${encodeURIComponent(query)}`,
{
headers: {
Accept: 'application/sparql-results+json',
'User-Agent': USER_AGENT,
},
},
);

if (!response.ok) {
throw new Error(
`Wikidata svarte ${response.status} ${response.statusText}. Prøv igjen om litt.`,
);
}

const json = (await response.json()) as {
results: { bindings: Record<string, { value: string } | undefined>[] };
};

const entities = new Map<string, WikidataEntity>();
for (const row of json.results.bindings) {
const id = row.item?.value.split('/').pop();
if (!id) continue;
// En ID kan gi flere rader når den har flere navn på samme språk.
// Første treff beholdes, så resultatet blir det samme hver gang.
const existing = entities.get(id);
entities.set(id, {
nbLabel: existing?.nbLabel ?? row.nbLabel?.value ?? null,
enLabel: existing?.enLabel ?? row.enLabel?.value ?? null,
});
}
return entities;
}

/**
* Wikidata skriver noen verktøy med stor forbokstav («Båndsag»). Navnene skal
* brukes midt i titler, så første bokstav gjøres liten. Merkenavn berøres ikke.
*/
function toLowerFirst(text: string): string {
return text.charAt(0).toLocaleLowerCase('nb-NO') + text.slice(1);
}

function buildToolTypes(entities: Map<string, WikidataEntity>): ToolType[] {
const missing: string[] = [];

const toolTypes = toolTypeSources.map((source): ToolType => {
const entity = entities.get(source.wikidataId);
const name = entity?.nbLabel ?? source.fallbackName;
if (!entity || !name) {
missing.push(source.wikidataId);
}
return {
wikidataId: source.wikidataId,
name: toLowerFirst(name ?? ''),
category: source.category,
nameSource: entity?.nbLabel ? 'wikidata' : 'manual',
};
});

if (missing.length > 0) {
throw new Error(
`Fant ikke navn for ${missing.join(', ')}. Sjekk ID-ene eller legg til fallbackName i toolSources.ts.`,
);
}

const duplicates = toolTypes
.map((type) => type.name)
.filter((name, index, names) => names.indexOf(name) !== index);
if (duplicates.length > 0) {
throw new Error(
`Samme verktøynavn finnes flere ganger: ${duplicates.join(', ')}.`,
);
}

return toolTypes.sort(
(a, b) =>
a.category.localeCompare(b.category) ||
a.name.localeCompare(b.name, 'nb'),
);
}

function buildBrands(entities: Map<string, WikidataEntity>): Brand[] {
return brandSources
.map((source): Brand => {
const entity = source.wikidataId
? entities.get(source.wikidataId)
: undefined;
if (source.wikidataId && !entity) {
throw new Error(
`Fant ikke ${source.wikidataId} (${source.name}) i Wikidata.`,
);
}
return {
name: source.name,
wikidataId: source.wikidataId,
wikidataLabel: entity?.enLabel ?? entity?.nbLabel ?? null,
categories: source.categories,
};
})
.sort((a, b) => a.name.localeCompare(b.name, 'nb'));
}

async function importToolModels(): Promise<void> {
const ids = [
...toolTypeSources.map((source) => source.wikidataId),
...brandSources.flatMap((source) =>
source.wikidataId ? [source.wikidataId] : [],
),
];
const entities = await fetchEntities([...new Set(ids)]);

const file: ToolModelsFile = {
source: {
name: 'Wikidata',
url: 'https://www.wikidata.org',
license: 'CC0 1.0',
retrieved: new Date().toISOString().slice(0, 10),
},
toolTypes: buildToolTypes(entities),
brands: buildBrands(entities),
};

await mkdir(dirname(outputPath), { recursive: true });
await writeFile(outputPath, `${JSON.stringify(file, null, 2)}\n`, 'utf8');

const fromWikidata = file.toolTypes.filter(
(type) => type.nameSource === 'wikidata',
).length;
const brandsWithId = file.brands.filter((brand) => brand.wikidataId).length;
console.info(
`Lagret ${file.toolTypes.length} verktøytyper (${fromWikidata} med norsk navn fra Wikidata) og ${file.brands.length} merker (${brandsWithId} med Wikidata-ID) i ${outputPath}.`,
);
}

await importToolModels();
Loading