diff --git a/2026-04-19 13.30.00 teeoff.no 44994b2e2831.jpg b/2026-04-19 13.30.00 teeoff.no 44994b2e2831.jpg deleted file mode 100644 index 3519f92..0000000 Binary files a/2026-04-19 13.30.00 teeoff.no 44994b2e2831.jpg and /dev/null differ diff --git a/backend/import_social_golfamore_csv.py b/backend/import_social_golfamore_csv.py new file mode 100644 index 0000000..50910fb --- /dev/null +++ b/backend/import_social_golfamore_csv.py @@ -0,0 +1,270 @@ +import argparse +import asyncio +import csv +import json +import re +import unicodedata +from dataclasses import dataclass +from pathlib import Path + +import asyncpg + +from env_config import get_database_url + + +DEFAULT_CSV_PATH = Path("/opt/teeoff/Regneark uten navn - Ark 1.csv") +DB_URL = get_database_url() + +SLUG_OVERRIDES = { + "Bodø Golfpark": "salten-golfklubb-bodo-golfpark", + "Hemsedal (IKKE ALPIN)": "hemsedal-golfklubb", + "Randsfjorden": "land-golfklubb", + "Romerike": "aurskog-golfpark", + "Steinkjær": "steinkjer-golfklubb", + "Tønsberg Re": "re-golfklubb", +} + + +@dataclass +class FacilityMatch: + id: int + slug: str + name: str + normalized_blob: str + tokens: set[str] + + +def normalize_text(value: str) -> str: + source = (value or "").translate( + str.maketrans( + { + "æ": "ae", + "ø": "o", + "å": "a", + "Æ": "Ae", + "Ø": "O", + "Å": "A", + } + ) + ) + normalized = unicodedata.normalize("NFKD", source) + ascii_text = normalized.encode("ascii", "ignore").decode("ascii").lower() + ascii_text = re.sub(r"\([^)]*\)", " ", ascii_text) + ascii_text = ascii_text.replace("&", " and ") + ascii_text = re.sub(r"[^a-z0-9]+", " ", ascii_text) + return re.sub(r"\s+", " ", ascii_text).strip() + + +def tokenize(value: str) -> set[str]: + return {token for token in normalize_text(value).split() if token} + + +def clean_cell(value: str | None) -> str | None: + if value is None: + return None + cleaned = value.strip() + return cleaned or None + + +def build_social_links(facebook_url: str | None, instagram_url: str | None) -> list[dict[str, str]]: + social_links: list[dict[str, str]] = [] + if facebook_url: + social_links.append({"platform": "Facebook", "url": facebook_url}) + if instagram_url: + social_links.append({"platform": "Instagram", "url": instagram_url}) + return social_links + + +def build_golfamore_data(description: str | None) -> dict[str, str]: + if not description: + return {} + return {"terms": description} + + +def select_facility(row_name: str, facilities: list[FacilityMatch]) -> FacilityMatch: + override_slug = SLUG_OVERRIDES.get(row_name) + if override_slug: + for facility in facilities: + if facility.slug == override_slug: + return facility + raise ValueError(f"Fant ikke override-slug '{override_slug}' for '{row_name}'.") + + query_tokens = tokenize(row_name) + query_norm = normalize_text(row_name) + if not query_tokens: + raise ValueError(f"Tom eller ugyldig anleggsidentifikator: '{row_name}'.") + + scored: list[tuple[int, FacilityMatch]] = [] + for facility in facilities: + if not query_tokens.issubset(facility.tokens): + continue + + score = 0 + if query_norm == normalize_text(facility.slug): + score += 100 + if query_norm == normalize_text(facility.name): + score += 100 + if query_norm and query_norm in facility.normalized_blob: + score += 25 + score += max(0, 20 - (len(facility.tokens) - len(query_tokens))) + scored.append((score, facility)) + + if not scored: + raise ValueError(f"Fant ingen facility-match for '{row_name}'.") + + scored.sort(key=lambda item: (-item[0], item[1].name)) + best_score = scored[0][0] + best_matches = [facility for score, facility in scored if score == best_score] + + if len(best_matches) != 1: + options = ", ".join(f"{facility.name} ({facility.slug})" for facility in best_matches) + raise ValueError(f"Flertydig match for '{row_name}': {options}") + + return best_matches[0] + + +async def fetch_facilities(conn: asyncpg.Connection) -> list[FacilityMatch]: + rows = await conn.fetch("SELECT id, slug, name FROM facilities") + facilities: list[FacilityMatch] = [] + for row in rows: + blob = f"{row['name']} {row['slug']}" + facilities.append( + FacilityMatch( + id=row["id"], + slug=row["slug"], + name=row["name"], + normalized_blob=normalize_text(blob), + tokens=tokenize(blob), + ) + ) + return facilities + + +async def run_import(csv_path: Path, apply_changes: bool) -> None: + if not csv_path.exists(): + raise FileNotFoundError(f"Fant ikke CSV-fil: {csv_path}") + + with csv_path.open("r", encoding="utf-8-sig", newline="") as handle: + reader = csv.DictReader(handle) + rows = list(reader) + + conn = await asyncpg.connect(DB_URL) + try: + facilities = await fetch_facilities(conn) + updates: list[dict[str, object]] = [] + warnings: list[str] = [] + + for row in rows: + row_name = clean_cell(row.get("Anlegg")) + if not row_name: + continue + + facility = select_facility(row_name, facilities) + facebook_url = clean_cell(row.get("Facebook")) + instagram_url = clean_cell(row.get("Instagram")) + golfamore_description = clean_cell(row.get("Golfamore beskrivelse")) + golfamore_url = clean_cell(row.get("Golfamore url")) + social_links = build_social_links(facebook_url, instagram_url) + golfamore_data = build_golfamore_data(golfamore_description) + + if facebook_url and "facebook.com" not in facebook_url.lower(): + warnings.append(f"{row_name}: Facebook-kolonnen peker ikke til facebook.com -> {facebook_url}") + if instagram_url and "instagram.com" not in instagram_url.lower(): + warnings.append(f"{row_name}: Instagram-kolonnen peker ikke til instagram.com -> {instagram_url}") + + updates.append( + { + "row_name": row_name, + "facility_id": facility.id, + "slug": facility.slug, + "name": facility.name, + "facebook_url": facebook_url, + "instagram_url": instagram_url, + "social_links": social_links, + "golfamore": bool(golfamore_description or golfamore_url), + "golfamore_url": golfamore_url, + "golfamore_data": golfamore_data, + } + ) + + print(f"Klar til å oppdatere {len(updates)} anlegg fra {csv_path}.") + for update in updates: + print(f"- {update['row_name']} -> {update['name']} ({update['slug']})") + + if warnings: + print("\nAdvarsler:") + for warning in warnings: + print(f"- {warning}") + + if not apply_changes: + print("\nDry-run fullført. Ingen data ble skrevet.") + return + + async with conn.transaction(): + for update in updates: + await conn.execute( + """ + UPDATE facilities + SET + facebook_url = $1, + instagram_url = $2, + social_links = $3::jsonb, + golfamore = $4, + golfamore_url = $5, + golfamore_data = $6::jsonb + WHERE id = $7 + """, + update["facebook_url"], + update["instagram_url"], + json.dumps(update["social_links"]), + update["golfamore"], + update["golfamore_url"], + json.dumps(update["golfamore_data"]), + update["facility_id"], + ) + + await conn.execute( + """ + WITH cleaned AS ( + SELECT + id, + COALESCE( + ( + SELECT jsonb_agg(entry) + FROM jsonb_array_elements(COALESCE(social_links, '[]'::jsonb)) AS entry + WHERE NULLIF(BTRIM(entry->>'url'), '') IS NOT NULL + AND NULLIF(BTRIM(entry->>'platform'), '') IS NOT NULL + ), + '[]'::jsonb + ) AS social_links + FROM facilities + ) + UPDATE facilities AS target + SET + facebook_url = NULLIF(BTRIM(target.facebook_url), ''), + instagram_url = NULLIF(BTRIM(target.instagram_url), ''), + social_links = cleaned.social_links + FROM cleaned + WHERE target.id = cleaned.id + """ + ) + + print("\nImport fullført.") + finally: + await conn.close() + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Importer sosiale medier og Golfamore-felter fra CSV.") + parser.add_argument("--csv", type=Path, default=DEFAULT_CSV_PATH, help="Sti til CSV-filen som skal importeres.") + parser.add_argument( + "--apply", + action="store_true", + help="Skriver endringene til databasen. Uten dette kjøres bare dry-run.", + ) + return parser.parse_args() + + +if __name__ == "__main__": + arguments = parse_args() + asyncio.run(run_import(arguments.csv, arguments.apply)) diff --git a/frontend/src/app/admin/[alias]/page.tsx b/frontend/src/app/admin/[alias]/page.tsx new file mode 100644 index 0000000..dd76bec --- /dev/null +++ b/frontend/src/app/admin/[alias]/page.tsx @@ -0,0 +1,17 @@ +import { notFound, redirect } from "next/navigation"; +import { resolveFacilityAlias } from "@/app/facilityAliases"; + +type AdminFacilityAliasPageProps = { + params: Promise<{ alias: string }>; +}; + +export default async function AdminFacilityAliasPage({ params }: AdminFacilityAliasPageProps) { + const { alias } = await params; + const facilitySlug = await resolveFacilityAlias(alias); + + if (!facilitySlug) { + notFound(); + } + + redirect(`/admin/rediger/${facilitySlug}`); +} diff --git a/frontend/src/app/golfbaner/[slug]/FacilityDetailView.tsx b/frontend/src/app/golfbaner/[slug]/FacilityDetailView.tsx index 9d75962..cfcd86a 100644 --- a/frontend/src/app/golfbaner/[slug]/FacilityDetailView.tsx +++ b/frontend/src/app/golfbaner/[slug]/FacilityDetailView.tsx @@ -198,7 +198,11 @@ export default function FacilityDetailView({ facility }: { facility: any }) { const golfamoreData = parseJson(facility.golfamore_data, {}); const nsgData = parseJson(facility.nsg_data, {}); const socialLinksRaw = parseJson(facility.social_links, []); - const socialLinks = Array.isArray(socialLinksRaw) ? socialLinksRaw : []; + const socialLinks = (Array.isArray(socialLinksRaw) ? socialLinksRaw : []).filter((social: any) => { + const platform = typeof social?.platform === 'string' ? social.platform.trim() : ''; + const url = typeof social?.url === 'string' ? social.url.trim() : ''; + return Boolean(platform && url); + }); const coopClubsRaw = parseJson(facility.cooperating_clubs, []); const cooperatingClubs = Array.isArray(coopClubsRaw) ? coopClubsRaw : []; @@ -483,7 +487,7 @@ export default function FacilityDetailView({ facility }: { facility: any }) { href={facility.golfamore_url} target="_blank" rel="noopener noreferrer" - className="font-black text-[#ff5722] transition-colors hover:underline" + className="font-black !text-[#ff5722] visited:!text-[#ff5722] hover:!text-[#ff5722] hover:underline" > {golfamoreData.terms || golfamoreData.gyldighet || "Ja"} @@ -496,7 +500,7 @@ export default function FacilityDetailView({ facility }: { facility: any }) { Seniorgolf (NSG): {hasNSG && facility.nsg_url - ? Ja (Vis avtale) + ? Ja (Vis avtale) : (hasNSG ? Ja : "Nei") } @@ -511,7 +515,12 @@ export default function FacilityDetailView({ facility }: { facility: any }) {

{pakke.navn || 'Golfpakke'}

- {pakke.beskrivelse &&

{pakke.beskrivelse}

} + {pakke.beskrivelse && ( +
+ )}
diff --git a/frontend/src/app/klubbnummer/page.tsx b/frontend/src/app/klubbnummer/page.tsx index 743bfc1..986aee2 100644 --- a/frontend/src/app/klubbnummer/page.tsx +++ b/frontend/src/app/klubbnummer/page.tsx @@ -8,7 +8,7 @@ import { createPageMetadata, } from "@/app/seo"; -const pageTitle = "Klubbnummer"; +const pageTitle = "Klubbnummer i Golfbox"; const pageDescription = "Sorterbar oversikt over NGF-nummer og klubbnavn for norske golfanlegg på TeeOff."; @@ -62,8 +62,8 @@ export default async function ClubNumbersPage() { />