diff options
| author | ivar <i@oiee.no> | 2025-10-26 11:33:38 +0100 |
|---|---|---|
| committer | ivar <i@oiee.no> | 2025-10-26 11:33:38 +0100 |
| commit | 2e8ad7dc6d49361c6ee00dc628e119c0c06e2779 (patch) | |
| tree | becc4cde99eff6ed1d168f9e9c454c0f2bbbee9d /VegaData | |
| parent | 8de1187b627625b94ef8088de3e9255ccd17baf4 (diff) | |
| download | vegadata-2e8ad7dc6d49361c6ee00dc628e119c0c06e2779.tar.xz vegadata-2e8ad7dc6d49361c6ee00dc628e119c0c06e2779.zip | |
.
Diffstat (limited to 'VegaData')
| -rw-r--r-- | VegaData/Program.cs | 140 | ||||
| -rw-r--r-- | VegaData/Properties/launchSettings.json | 23 | ||||
| -rw-r--r-- | VegaData/VegaData.csproj | 37 | ||||
| -rw-r--r-- | VegaData/appsettings.json | 9 | ||||
| -rw-r--r-- | VegaData/data/main.db | bin | 0 -> 65536 bytes | |||
| -rw-r--r-- | VegaData/data/main.db-shm | bin | 0 -> 32768 bytes | |||
| -rw-r--r-- | VegaData/data/main.db-wal | bin | 0 -> 74192 bytes | |||
| -rw-r--r-- | VegaData/wwwroot/17.png | bin | 0 -> 5334 bytes | |||
| -rw-r--r-- | VegaData/wwwroot/framework.js | 166 | ||||
| -rw-r--r-- | VegaData/wwwroot/index.html | 139 | ||||
| -rw-r--r-- | VegaData/wwwroot/index.js | 152 | ||||
| -rw-r--r-- | VegaData/wwwroot/version.txt | 1 |
12 files changed, 667 insertions, 0 deletions
diff --git a/VegaData/Program.cs b/VegaData/Program.cs new file mode 100644 index 0000000..76fd8e2 --- /dev/null +++ b/VegaData/Program.cs @@ -0,0 +1,140 @@ +using System.Text.Json.Serialization; +using Microsoft.EntityFrameworkCore; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddHttpClient(); +builder.Services.AddDbContext<Database>(o => o.UseSqlite("Data Source=data/main.db")); +builder.Services.AddHostedService<DataWorker>(); + +var app = builder.Build(); +try { + using var scope = app.Services.CreateScope(); + scope.ServiceProvider.GetRequiredService<Database>().Database.EnsureCreated(); +} catch (Exception e) { + Console.WriteLine(e); +} + +app.MapStaticAssets(); +app.MapGet("shows", (Database db) => db.Shows.Where(c => c.StartDateTime >= DateTime.Now).ToList()); +app.MapGet("tz", () => TimeZoneInfo.Local.Id); +app.MapGet("/", () => Results.Redirect("/index.html")); +app.Run(); + +class Database(DbContextOptions<Database> options) : DbContext(options) +{ + public DbSet<Show> Shows { get; set; } + + protected override void OnModelCreating(ModelBuilder modelBuilder) { + modelBuilder.Entity<Show>().HasIndex(p => new { + p.Title, + p.StartDateTime + }) + .IsUnique(); + base.OnModelCreating(modelBuilder); + } +} + +class Show +{ + public int Id { get; set; } + public required string Title { get; set; } + public required string Scene { get; set; } + public required DateTime StartDateTime { get; set; } + public required string TicketUrl { get; set; } + public required string Type { get; set; } + public required string MovieVersion { get; set; } + public required string MovieMainVersion { get; set; } + public required string[] Tags { get; set; } +} + +class DataWorker(IServiceScopeFactory serviceScopeFactory) : BackgroundService +{ + protected override async Task ExecuteAsync(CancellationToken stoppingToken) { + const int interval = 1000 * 60 * 60; + while (!stoppingToken.IsCancellationRequested) { + try { + await Work(stoppingToken); + await Task.Delay(interval, stoppingToken); + } catch (Exception e) { + Console.WriteLine(e); + } + } + } + + async Task Work(CancellationToken ct) { + using var scope = serviceScopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService<Database>(); + var http = scope.ServiceProvider.GetRequiredService<HttpClient>(); + var logger = scope.ServiceProvider.GetRequiredService<ILogger<DataWorker>>(); + var refreshData = Environment.GetEnvironmentVariable("VEGADATA_REFRESH_DATA") == "1"; + logger.LogInformation("Syncing data. Refresh={RefreshData}", refreshData); + var shows = await http.GetFromJsonAsync<ShowsResponse>("https://www.vegascene.no/api/program", ct); + var addedCount = 0; + if (refreshData) { + File.Copy("data/main.db", $"data/main-{Random.Shared.Next(10000)}.db"); + db.Shows.RemoveRange(db.Shows); + await db.SaveChangesAsync(ct); + } + + var count = await db.Shows.CountAsync(ct); + + foreach (var show in shows?.Shows ?? []) { + if (string.IsNullOrWhiteSpace(show.TicketSaleUrl)) continue; + var existing = await db.Shows.AnyAsync(s => s.Title == show.MovieTitle && s.StartDateTime == show.ShowStart, ct); + if (existing) continue; + logger.LogInformation("Adding show {Title} at {StartDateTime}", show.MovieTitle, show.ShowStart); + db.Shows.Add(new Show() { + Title = show.MovieTitle, + Scene = show.ScreenName, + StartDateTime = show.ShowStart, + MovieMainVersion = show.MovieMainVersionId, + MovieVersion = show.MovieVersionId, + TicketUrl = show.TicketSaleUrl, + Type = show.ShowType, + Tags = show.VersionTags.Select(t => t.Tag).ToArray() + }); + addedCount++; + } + await db.SaveChangesAsync(ct); + logger.LogInformation("Synced data, old count:{Count}, new count:{AddedCount}", count, addedCount); + } + + class ShowsResponse + { + public required List<VegaShow> Shows { get; set; } + } + + class VegaShow + { + [JsonPropertyName("screenName")] + public required string ScreenName { get; set; } + + [JsonPropertyName("ticketSaleUrl")] + public required string TicketSaleUrl { get; set; } + + [JsonPropertyName("showType")] + public required string ShowType { get; set; } + + [JsonPropertyName("showStart")] + public required DateTime ShowStart { get; set; } + + [JsonPropertyName("movieVersionId")] + public required string MovieVersionId { get; set; } + + [JsonPropertyName("movieMainVersionId")] + public required string MovieMainVersionId { get; set; } + + [JsonPropertyName("movieTitle")] + public required string MovieTitle { get; set; } + + [JsonPropertyName("versionTags")] + public required VersionTag[] VersionTags { get; set; } + } + + public class VersionTag + { + [JsonPropertyName("tag")] + public required string Tag { get; set; } + } +}
\ No newline at end of file diff --git a/VegaData/Properties/launchSettings.json b/VegaData/Properties/launchSettings.json new file mode 100644 index 0000000..e579794 --- /dev/null +++ b/VegaData/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5234", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "https://localhost:7097;http://localhost:5234", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/VegaData/VegaData.csproj b/VegaData/VegaData.csproj new file mode 100644 index 0000000..2604474 --- /dev/null +++ b/VegaData/VegaData.csproj @@ -0,0 +1,37 @@ +<Project Sdk="Microsoft.NET.Sdk.Web"> + + <PropertyGroup> + <TargetFramework>net10.0</TargetFramework> + <Nullable>enable</Nullable> + <ImplicitUsings>enable</ImplicitUsings> + <DockerDefaultTargetOS>Linux</DockerDefaultTargetOS> + </PropertyGroup> + + <ItemGroup> + <PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.9" /> + <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.9"> + <PrivateAssets>all</PrivateAssets> + <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> + </PackageReference> + <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.9" /> + </ItemGroup> + + <ItemGroup> + <Content Include="..\.dockerignore"> + <Link>.dockerignore</Link> + </Content> + </ItemGroup> + + <ItemGroup> + <Folder Include="data\" /> + </ItemGroup> + + <ItemGroup> + <None Include="wwwroot\17.png" /> + </ItemGroup> + + <ItemGroup> + <_ContentIncludedByDefault Remove="wwwroot\Baskerville No.2 Regular\Baskerville No.2 Regular.otf" /> + </ItemGroup> + +</Project> diff --git a/VegaData/appsettings.json b/VegaData/appsettings.json new file mode 100644 index 0000000..10f68b8 --- /dev/null +++ b/VegaData/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/VegaData/data/main.db b/VegaData/data/main.db Binary files differnew file mode 100644 index 0000000..9b09852 --- /dev/null +++ b/VegaData/data/main.db diff --git a/VegaData/data/main.db-shm b/VegaData/data/main.db-shm Binary files differnew file mode 100644 index 0000000..ef4b59b --- /dev/null +++ b/VegaData/data/main.db-shm diff --git a/VegaData/data/main.db-wal b/VegaData/data/main.db-wal Binary files differnew file mode 100644 index 0000000..0239d4f --- /dev/null +++ b/VegaData/data/main.db-wal diff --git a/VegaData/wwwroot/17.png b/VegaData/wwwroot/17.png Binary files differnew file mode 100644 index 0000000..7895530 --- /dev/null +++ b/VegaData/wwwroot/17.png diff --git a/VegaData/wwwroot/framework.js b/VegaData/wwwroot/framework.js new file mode 100644 index 0000000..d1e6cc6 --- /dev/null +++ b/VegaData/wwwroot/framework.js @@ -0,0 +1,166 @@ +const targetMap = new WeakMap(); +let activeEffect = null; + +const root = document.getElementById("root"); + +function createElement(tagName, props = {}, children = []) { + const el = document.createElement(tagName); + + for (const [key, value] of Object.entries(props || {})) { + if (key.startsWith("on") && typeof value === "function") { + el.addEventListener(key.substring(2).toLowerCase(), value); + } else if (key === "style" && typeof value === "object") { + Object.assign(el.style, value); + } else { + el.setAttribute(key, value); + } + } + + const appendChild = (child) => { + if (Array.isArray(child)) { + child.forEach(appendChild); + } else if (typeof child === "function") { + const placeholder = document.createTextNode(""); + el.appendChild(placeholder); + e(() => { + placeholder.textContent = child() ?? ""; + }); + } else if (child instanceof Node) { + el.appendChild(child); + } else if (child != null) { + el.appendChild(document.createTextNode(String(child))); + } + }; + + children.forEach(appendChild); + return el; +} + +function track(target, key) { + if (!activeEffect) { + return; + } + let depsMap = targetMap.get(target); + if (!depsMap) { + depsMap = new Map(); + targetMap.set(target, depsMap); + } + + let dep = depsMap.get(key); + if (!dep) { + dep = new Set(); + depsMap.set(key, dep); + } + + dep.add(activeEffect); +} + +function trigger(target, key) { + const depsMap = targetMap.get(target); + if (!depsMap) { + return; + } + + const dep = depsMap.get(key); + if (dep) { + dep.forEach((effect) => effect()); + } +} + +//** +// Create a reactive value, the value is at .value. +// To use this in element props you need to supply the .value read as a function. +// */ +export function r(target) { + target = { value: target }; + return new Proxy(target, { + get(obj, key, receiver) { + track(obj, key); + return Reflect.get(obj, key, receiver); + }, + set(obj, key, value, receiver) { + const result = Reflect.set(obj, key, value, receiver); + trigger(obj, key); + return result; + }, + }); +} + +//** +// Run code when value changes +// */ +export function e(fn) { + let active = true; + + const runner = () => { + if (active) { + activeEffect = runner; + fn(); + activeEffect = null; + } + }; + + runner(); + + runner.stop = () => { + active = false; + }; + + return runner; +} + +export function gqp(key) { + return new URLSearchParams(location.search).get(key) +} + +export function sqp(query) { + if ('URLSearchParams' in window) { + const url = new URL(window.location) + for (const key of Object.keys(query)) { + const value = encodeURIComponent(query[key]) + if (!value || value === "") url.searchParams.delete(key) + else url.searchParams.set(key, value) + } + history.pushState(null, '', url); + } +} + +//** +// Combine elements +// */ +export function c(a, b) { + const normalize = (x) => (x == null ? [] : Array.isArray(x) ? x : [x]); + + return [...normalize(a), ...normalize(b)]; +} + +//** +// Mount element to a target (target is #root by default) +// */ +export async function m(component, target = root) { + if (typeof component === "function") { + component = await component(); + } + if (Array.isArray(component)) { + target.append(...component); + } else { + target.appendChild(component); + } +} + +export function css(styleObject) { + return Object.entries(styleObject).map(([key, value]) => { + const kebabKey = key.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase(); + return `${kebabKey}: ${value}`; + }).join("; "); +} + +//** +// Create element +// */ +export function t(name, props, ...children) { + if (typeof name === "function") { + return name({ ...props, children }); + } + return createElement(name, props, children); +} diff --git a/VegaData/wwwroot/index.html b/VegaData/wwwroot/index.html new file mode 100644 index 0000000..23fbb8e --- /dev/null +++ b/VegaData/wwwroot/index.html @@ -0,0 +1,139 @@ +<!doctype html> +<html lang="nb"> + +<head> + <meta charset="UTF-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <style> + * { + box-sizing: border-box; + } + + :root { + --bg: beige; + } + + #search { + padding: 10px 12px; + min-width: 300px; + } + + html, + body { + margin: 0; + padding: 0; + background: var(--bg); + font-family: Palatino, Georgia, serif; + } + + li:target, + .activeShow { + padding-block: 2rem; + padding-inline: 1rem; + background-image: url("./17.png"); + + div:first-of-type { + padding-block: .4rem; + padding-inline-start: .5rem; + background-color: var(--bg); + } + } + + #ulShows { + height: 80vh; + overflow-x: auto; + } + + #times { + padding-left: 15px; + } + + main { + padding-inline: 10px; + } + + .show { + margin-bottom: 1rem; + width: 100%; + + .title { + position: sticky; + top: 0; + width: 100%; + display: flex; + align-items: baseline; + justify-content: space-between; + background: bisque; + padding-inline: 3px; + padding-bottom: 5px; + + h2 { + font-weight: 600; + overflow-wrap: break-word; + hyphens: manual; + margin-top: 0; + margin-bottom: 0; + } + } + } + + .time { + font-style: normal; + font-size: 1rem; + text-decoration: none; + font-weight: 400; + margin-bottom: 15px; + + .date::first-letter { + border-left: 2px solid gold; + padding-left: 2px; + } + + div { + display: flex; + flex-direction: column; + + span:first-of-type { + font-size: 1.2rem; + } + } + + .actions { + display: flex; + flex-direction: row; + gap: 1rem; + } + } + + h1 { + margin: 0; + padding: 10px; + } + + ul { + list-style: none; + margin: 10px 0 0 0; + padding: 0; + } + </style> + <title>Vega eller</title> +</head> + +<body> + <h1>Bli med på vega?</h1> + <main> + <button id="renderShowsBtn">tilbakestill</button> <br> + <input type="search" name="q1" id="search" placeholder="søk"> + <ul id="ulShows"></ul> + </main> + <script type="importmap"> + { + "imports": { + "temporal-polyfill": "https://esm.sh/temporal-polyfill@0.3.0" + } + } + </script> + <script type="module" src="index.js"></script> +</body> + +</html>
\ No newline at end of file diff --git a/VegaData/wwwroot/index.js b/VegaData/wwwroot/index.js new file mode 100644 index 0000000..45af7f2 --- /dev/null +++ b/VegaData/wwwroot/index.js @@ -0,0 +1,152 @@ +import { Temporal } from "temporal-polyfill"; +import { gqp, sqp, t } from "./framework.js"; + +let _; + +async function getShows() { + if (_) return _; + const response = await fetch("/shows"); + _ = await response.json(); + return _; +} + +const ulShows = document.getElementById("ulShows"); +const search = document.getElementById("search"); +const renderShowsBtn = document.getElementById("renderShowsBtn"); + +renderShowsBtn.addEventListener("click", () => { + renderShows(""); + search.value = ""; +}); + +function dateString(date, small = false) { + const instant = Temporal.Instant.from(`${date}Z`) + const stringOptions = { + weekday: "long", + hour: "2-digit", + timeZone: "UTC", + minute: "2-digit", + month: "long", + calendar: "gregory", + day: "numeric" + } + + if (small) { + return instant.toLocaleString("nb-NO", stringOptions); + } + + return instant.toLocaleString("nb-NO", { + year: "numeric", + era: "long", + ...stringOptions + }); +} + +function copyLink(t, e) { + const initialInnerText = t.target.innerText; + if ("clipboard" in navigator) { + navigator.clipboard.writeText(`${location.origin}/index.html#${urlId(e)}`) + t.target.innerText = `${initialInnerText} ✓` + setTimeout(() => { + t.target.innerText = initialInnerText + }, 1000) + } +} + +function vegascene(showing) { + if (showing.movieMainVersion.startsWith("KUL")) return `https://www.vegascene.no/teater/${showing.movieMainVersion}` + return `https://www.vegascene.no/film/${showing.movieMainVersion}` +} + +function urlId(e) { + return `${e.movieVersion}${e.startDateTime}`.replaceAll("-", "").replaceAll(" ", "").replaceAll(":", "") +} + +async function renderShows(query, rendered) { + query = query?.trim(); + sqp({ q: query ?? "" }) + + const shows = (await getShows()).reduce((acc, curr) => { + const key = curr.title; + if (!acc[key]) { + acc[key] = []; + } + acc[key].push(curr); + return acc; + }, {}); + + async function share(show) { + const shareData = { + title: `${show.title} ${dateString(show.startDateTime, true)} på vega`, + url: `${location.origin}/index.html#${urlId(show)}`, + } + await navigator.share(shareData); + } + + const lis = []; + + for (const showKey of Object.keys(shows).sort((a, b) => a.localeCompare(b))) { + const times = shows[showKey].sort((a, b) => Temporal.PlainDate.compare(Temporal.PlainDate.from(a.startDateTime), Temporal.PlainDate.from(b.startDateTime))) + if (query) { + const words = [showKey.toLowerCase()] + for (const time of times) { + words.push(dateString(time.startDateTime)) + words.push(time.scene) + words.push(time.tags.join(" ")) + } + if (!words.some((word) => word.match(query.toLowerCase()))) continue; + } + + lis.push( + t("li", { class: "show", id: showKey }, [ + t("div", { class: "title" }, [ + t("h2", { class: "italic" }, showKey), + t("a", { href: vegascene(shows[showKey][0]) }, "(Åpne på vegascene.no)") + ]), + t("ul", { id: "times" }, [ + t("li", undefined, + times.filter(e => e.ticketUrl !== "").map(e => { + let tagLine = `${e.scene} - ${[e.type, ...e.tags].join(", ")}` + tagLine = tagLine[0].toUpperCase() + tagLine.slice(1) + let dateLine = dateString(e.startDateTime) + dateLine = dateLine[0].toUpperCase() + dateLine.slice(1) + return t("li", { class: `time time-${e.id}`, id: urlId(e) }, [ + t("div", undefined, [ + t("span", { title: e.startDateTime, class: "date" }, dateLine), + t("span", undefined, tagLine), + t("div", { class: "actions" }, [ + t("a", { href: e.ticketUrl }, "Billetter"), + "share" in navigator ? t("button", { onclick: () => share(e) }, "Del tid") : null, + t("button", { onclick: (t) => copyLink(t, e) }, "Kopier lenke") + ]) + ]) + ]) + })) + ]) + ]) + ) + } + + if (!lis.length) ulShows.replaceChildren(...[t("i", undefined, "Dessverre")]) + else ulShows.replaceChildren(...lis); + + const interval = setInterval(() => { + const element = document.querySelector("#ulShows"); + if (element.childNodes.length > 0) { + clearInterval(interval); + rendered(); + } + }, 50); +} + +renderShows(search.value, () => { + if (gqp("q")) search.value = gqp("q") + search.addEventListener("input", e => renderShows(e.currentTarget.value)); + const id = location.href.indexOf("#") !== -1 ? location.href.substring(location.href.indexOf("#")) : "" + if (id !== "") { + const target = document.querySelector(id) + target.scrollIntoView({ behavior: "smooth", block: "center" }) + target.classList.add("activeShow") + document.querySelectorAll(".time").forEach(el => el.id !== id.slice(1) && el.classList.remove("activeShow")) + } +});
\ No newline at end of file diff --git a/VegaData/wwwroot/version.txt b/VegaData/wwwroot/version.txt new file mode 100644 index 0000000..a97cee7 --- /dev/null +++ b/VegaData/wwwroot/version.txt @@ -0,0 +1 @@ +v29 |
