corpus

Log | Files | Refs | README | LICENSE

commit e394d45dba7c024d0a5057945d4261fe623c8048
parent db67d60f9d5278957dde85a2cb078dc7c573eb36
Author: mtmn <miro@haravara.org>
Date:   Sat, 11 Apr 2026 02:24:24 +0200

feat: fix builds, inline htmx to ps-halogen

Former-commit-id: fb42be49616b3310c9207a406763988b992eb8da

Diffstat:
M.gitignore | 2++
A.purs-repl | 1+
Dindex.html | 250-------------------------------------------------------------------------------
Mpackage-lock.json.REMOVED.git-id | 4++--
Mpackage.json | 17++++++++++++-----
Dpackages.dhall | 106-------------------------------------------------------------------------------
Aserver.log | 0
Dspago.dhall | 18------------------
Aspago.lock | 2168+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aspago.yaml | 44++++++++++++++++++++++++++++++++++++++++++++
Asrc/Client.purs | 213+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/Main.purs | 302++++++++++++++++++++++++++++++++++++++++++++++---------------------------------
Asrc/Types.purs | 82+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atest/Main.purs | 11+++++++++++
14 files changed, 2713 insertions(+), 505 deletions(-)

diff --git a/.gitignore b/.gitignore @@ -1,3 +1,5 @@ +*.js output/ .spago/ node_modules/ +.env diff --git a/.purs-repl b/.purs-repl @@ -0,0 +1 @@ +import Prelude diff --git a/index.html b/index.html @@ -1,250 +0,0 @@ -<!DOCTYPE html> -<html lang="en"> -<head> - <meta charset="UTF-8"> - <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <title>scrobbler.mtmn.name</title> - <script src="https://unpkg.com/htmx.org@2.0.7"></script> - <style> - body { - font-family: 'Courier New', 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; - background: #332d38; - color: #ffffff; - margin: 0; - padding: 20px; - line-height: 1.6; - } - - .container { - max-width: 800px; - margin: 0 auto; - } - - h1 { - color: #ffffff; - margin-bottom: 20px; - font-size: 24px; - } - - ul { - list-style: none; - padding: 0; - margin: 0 0 20px 0; - } - - li { - background: rgba(0, 0, 0, 0.3); - border: 1px solid rgba(80, 68, 127, 0.5); - border-radius: 4px; - padding: 15px; - margin-bottom: 10px; - display: flex; - justify-content: space-between; - align-items: center; - } - - li.success { - background: rgba(185, 208, 170, 0.2); - border-color: rgba(185, 208, 170, 0.3); - } - - .track-info { - flex: 1; - } - - .track-name { - font-weight: bold; - font-size: 16px; - color: #ffffff; - } - - .track-artist { - font-size: 14px; - color: #a0c0d0; - margin-top: 4px; - } - - .track-time { - font-size: 12px; - color: #9fbfe7; - margin-top: 2px; - } - - .status { - color: #b9d0aa; - font-weight: bold; - } - - .loading { - padding: 20px; - color: #9fbfe7; - text-align: center; - } - - .error { - padding: 20px; - color: #eca28f; - text-align: center; - } - - .small { - font-size: 12px; - color: #9fbfe7; - margin-top: 20px; - } - - .small a { - color: #a0c0d0; - text-decoration: none; - } - - .small a:hover { - color: #ffffff; - text-decoration: underline; - } - - .refresh-btn { - background: none; - border: none; - color: #a0c0d0; - cursor: pointer; - font-size: 12px; - text-decoration: underline; - } - - .refresh-btn:hover { - color: #ffffff; - } - - .playing-indicator { - display: inline-block; - width: 8px; - height: 8px; - background: #b9d0aa; - border-radius: 50%; - margin-right: 8px; - animation: pulse 2s infinite; - } - - @keyframes pulse { - 0% { opacity: 1; } - 50% { opacity: 0.3; } - 100% { opacity: 1; } - } - </style> -</head> -<body> - <div class="container"> - <ul> - <li class="success">Now Playing</li> - </ul> - - <h1>Recent Tracks</h1> - <ul id="tracks-container" - hx-get="/api/1/user/mtmn/listens" - hx-trigger="load, every 30s" - hx-target="#tracks-container" - hx-ext="listenbrainz"> - <li class="loading">Loading recent tracks...</li> - </ul> - - <p class="small"> - <button class="refresh-btn" hx-get="/api/1/user/mtmn/listens" hx-target="#tracks-container">Refresh</button> • - <a href="https://listenbrainz.org/user/mtmn/">ListenBrainz</a> • - </p> - - <p class="small" id="last-updated"></p> - </div> - - <script> - // Update last updated time - function updateLastUpdated() { - const now = new Date(); - document.getElementById('last-updated').textContent = `Last check: ${now.toISOString()}`; - } - - // Handle ListenBrainz API response - htmx.defineExtension('listenbrainz', { - onEvent: function(name, evt) { - if (name === 'htmx:afterRequest') { - const xhr = evt.detail.xhr; - if (xhr.status === 200) { - try { - const data = JSON.parse(xhr.responseText); - if (data.payload && data.payload.listens) { - const html = generateTracksHTML(data.payload.listens); - document.getElementById('tracks-container').innerHTML = html; - } else { - document.getElementById('tracks-container').innerHTML = '<li class="error">No tracks found</li>'; - } - } catch (e) { - document.getElementById('tracks-container').innerHTML = '<li class="error">Error parsing response</li>'; - } - } else { - document.getElementById('tracks-container').innerHTML = '<li class="error">Error loading tracks</li>'; - } - updateLastUpdated(); - } - } - }); - - function generateTracksHTML(listens) { - let html = ''; - listens.forEach((listen, index) => { - const track = listen.track_metadata; - const listenedAt = formatTimeAgo(listen.listened_at); - - const trackName = track.track_name || 'Unknown Track'; - const artistName = track.artist_name || 'Unknown Artist'; - const releaseName = track.release_name || 'Unknown Album'; - - const playingIndicator = index === 0 ? '<span class="playing-indicator"></span>' : ''; - - html += ` - <li class="success"> - <div class="track-info"> - <div class="track-name">${playingIndicator}${escapeHtml(trackName)}</div> - <div class="track-artist">${escapeHtml(artistName)}</div> - <div class="track-time">${escapeHtml(releaseName)} • ${listenedAt}</div> - </div> - <span class="status">Played</span> - </li> - `; - }); - return html; - } - - function formatTimeAgo(timestamp) { - const now = Math.floor(Date.now() / 1000); - const diff = now - timestamp; - - if (diff < 60) { - return 'just now'; - } else if (diff < 3600) { - const minutes = Math.floor(diff / 60); - return `${minutes} minute${minutes > 1 ? 's' : ''} ago`; - } else if (diff < 86400) { - const hours = Math.floor(diff / 3600); - return `${hours} hour${hours > 1 ? 's' : ''} ago`; - } else { - const days = Math.floor(diff / 86400); - return `${days} day${days > 1 ? 's' : ''} ago`; - } - } - - function escapeHtml(text) { - const map = { - '&': '&amp;', - '<': '&lt;', - '>': '&gt;', - '"': '&quot;', - "'": '&#39;' - }; - return text.replace(/[&<>"']/g, m => map[m]); - } - - // Initial update - updateLastUpdated(); - </script> -</body> -</html> diff --git a/package-lock.json.REMOVED.git-id b/package-lock.json.REMOVED.git-id @@ -1 +1 @@ -cb42bac2fd6f70dc9423a238f450ac6dc2ad8a2d -\ No newline at end of file +0906d25db792f3ec30afece36314f061a3baf45a +\ No newline at end of file diff --git a/package.json b/package.json @@ -3,19 +3,26 @@ "version": "1.0.0", "description": "ListenBrainz scrobbler in PureScript", "main": "index.js", + "type": "module", "scripts": { - "build": "npx spago build", + "build": "npx spago build && npx esbuild output/Main/index.js --bundle --platform=node --format=esm --outfile=server.js --external:http --external:https --external:xhr2 --external:dotenv --external:url && npx spago bundle --module Client --outfile client.js --platform browser", + "build-client": "npx spago bundle --module Client --outfile client.js --platform browser", + "build-server": "npx esbuild output/Main/index.js --bundle --platform=node --format=esm --outfile=server.js --external:http --external:https --external:xhr2 --external:dotenv --external:url", "test": "npx spago test", "tidy": "purs-tidy format-in-place src/**/*.purs", - "serve": "npx spago build && node -e \"require('spago').run({cmd: 'bundle-app', main: 'Main', to: './index.js', platform: 'node'})\" && node ./index.js", - "start": "npx spago build && node -e \"require('spago').run({cmd: 'bundle-app', main: 'Main', to: './index.js', platform: 'node'})\" && node ./index.js" + "serve": "node server.js 2>&1 | tee server.log", + "start": "node server.js 2>&1 | tee server.log" }, "devDependencies": { "esbuild": "^0.28.0", - "purescript": "^0.15.15", + "purescript": "^0.15.16", "purescript-language-server": "^0.18.0", "purescript-psa": "^0.9.0", "purs-tidy": "^0.11.1", - "spago": "^0.21.0" + "spago": "^1.0.4" + }, + "dependencies": { + "dotenv": "^17.4.1", + "xhr2": "^0.2.1" } } diff --git a/packages.dhall b/packages.dhall @@ -1,105 +0,0 @@ -{- -Welcome to your new Dhall package-set! - -Below are instructions for how to edit this file for most use -cases, so that you don't need to know Dhall to use it. - -## Use Cases - -Most will want to do one or both of these options: -1. Override/Patch a package's dependency -2. Add a package not already in the default package set - -This file will continue to work whether you use one or both options. -Instructions for each option are explained below. - -### Overriding/Patching a package - -Purpose: -- Change a package's dependency to a newer/older release than the - default package set's release -- Use your own modified version of some dependency that may - include new API, changed API, removed API by - using your custom git repo of the library rather than - the package set's repo - -Syntax: -where `entityName` is one of the following: -- dependencies -- repo -- version -------------------------------- -let upstream = -- -in upstream - with packageName.entityName = "new value" -------------------------------- - -Example: -------------------------------- -let upstream = -- -in upstream - with halogen.version = "master" - with halogen.repo = "https://example.com/path/to/git/repo.git" - - with halogen-vdom.version = "v4.0.0" - with halogen-vdom.dependencies = [ "extra-dependency" ] # halogen-vdom.dependencies -------------------------------- - -### Additions - -Purpose: -- Add packages that aren't already included in the default package set - -Syntax: -where `<version>` is: -- a tag (i.e. "v4.0.0") -- a branch (i.e. "master") -- commit hash (i.e. "701f3e44aafb1a6459281714858fadf2c4c2a977") -------------------------------- -let upstream = -- -in upstream - with new-package-name = - { dependencies = - [ "dependency1" - , "dependency2" - ] - , repo = - "https://example.com/path/to/git/repo.git" - , version = - "<version>" - } -------------------------------- - -Example: -------------------------------- -let upstream = -- -in upstream - with benchotron = - { dependencies = - [ "arrays" - , "exists" - , "profunctor" - , "strings" - , "quickcheck" - , "lcg" - , "transformers" - , "foldable-traversable" - , "exceptions" - , "node-fs" - , "node-buffer" - , "node-readline" - , "datetime" - , "now" - ] - , repo = - "https://github.com/hdgarrood/purescript-benchotron.git" - , version = - "v7.0.0" - } -------------------------------- --} -let upstream = - https://github.com/purescript/package-sets/releases/download/psc-0.15.15-20260410/packages.dhall - sha256:790bcce8148c65d74e13a149145e5a26869d4d625c29093cb4ac0883d0cd3e05 - -in upstream -\ No newline at end of file diff --git a/server.log b/server.log diff --git a/spago.dhall b/spago.dhall @@ -1,18 +0,0 @@ -{ name = "scrobbler.purs" -, dependencies = - [ "aff" - , "console" - , "effect" - , "either" - , "exceptions" - , "node-buffer" - , "node-event-emitter" - , "node-http" - , "node-net" - , "node-streams" - , "prelude" - , "unsafe-coerce" - ] -, packages = ./packages.dhall -, sources = [ "src/**/*.purs" ] -} diff --git a/spago.lock b/spago.lock @@ -0,0 +1,2168 @@ +{ + "workspace": { + "packages": { + "scrobbler-purescript": { + "path": "./", + "core": { + "dependencies": [ + "aff", + "affjax", + "affjax-web", + "argonaut", + "argonaut-core", + "arrays", + "console", + "datetime", + "effect", + "either", + "exceptions", + "fetch", + "fetch-argonaut", + "foreign-object", + "halogen", + "http-methods", + "integers", + "js-uri", + "maybe", + "node-buffer", + "node-event-emitter", + "node-fs", + "node-http", + "node-net", + "node-process", + "node-streams", + "node-url", + "now", + "ordered-collections", + "prelude", + "strings", + "tailrec", + "unsafe-coerce", + "web-dom" + ] + }, + "test": { + "dependencies": [] + } + } + }, + "package_set": { + "address": { + "registry": "75.9.0" + }, + "compiler": ">=0.15.15 <0.16.0", + "content": { + "abc-parser": "2.1.0", + "ace": "9.1.0", + "address-rfc2821": "0.1.1", + "aff": "8.0.0", + "aff-bus": "6.0.0", + "aff-coroutines": "9.0.0", + "aff-promise": "4.0.0", + "aff-retry": "2.0.0", + "affjax": "13.0.0", + "affjax-node": "1.0.0", + "affjax-web": "1.0.0", + "ansi": "7.0.0", + "apexcharts": "0.5.0", + "applicative-phases": "1.0.0", + "argonaut": "9.0.0", + "argonaut-aeson-generic": "0.4.1", + "argonaut-codecs": "9.1.0", + "argonaut-core": "7.0.0", + "argonaut-generic": "8.0.0", + "argonaut-traversals": "10.0.0", + "argparse-basic": "2.0.0", + "array-builder": "0.1.2", + "array-search": "0.6.0", + "arraybuffer": "13.2.0", + "arraybuffer-builder": "3.1.0", + "arraybuffer-types": "3.0.2", + "arrays": "7.3.0", + "arrays-extra": "0.6.1", + "arrays-zipper": "2.0.1", + "ask": "1.0.0", + "assert": "6.0.0", + "assert-multiple": "0.4.0", + "avar": "5.0.1", + "axon": "0.0.3", + "b64": "0.0.8", + "barbies": "1.0.1", + "barlow-lens": "0.9.0", + "benchlib": "0.0.4", + "bifunctors": "6.1.0", + "bigints": "7.0.1", + "blessed": "1.0.0", + "bolson": "0.3.9", + "bookhound": "0.1.7", + "bower-json": "3.0.0", + "call-by-name": "4.0.1", + "canvas": "6.0.0", + "canvas-action": "9.0.0", + "cartesian": "1.0.6", + "catenable-lists": "7.0.0", + "cbor-stream": "1.3.0", + "chameleon": "1.0.0", + "chameleon-halogen": "1.0.3", + "chameleon-react-basic": "1.1.0", + "chameleon-styled": "2.5.0", + "chameleon-transformers": "1.0.0", + "channel": "1.0.0", + "chartjs": "0.1.1", + "chartjs-halogen": "0.1.0", + "checked-exceptions": "3.1.1", + "choku": "1.0.2", + "classless": "0.1.1", + "classless-arbitrary": "0.1.1", + "classless-decode-json": "0.1.1", + "classless-encode-json": "0.1.3", + "classnames": "2.0.0", + "codec": "6.1.0", + "codec-argonaut": "10.0.0", + "codec-json": "2.0.0", + "colors": "7.0.1", + "compile-fail": "0.4.0", + "concur-core": "0.5.0", + "concur-react": "0.5.0", + "concurrent-queues": "3.0.0", + "console": "6.1.0", + "const": "6.0.0", + "contravariant": "6.0.0", + "control": "6.0.0", + "convertable-options": "1.0.0", + "coroutines": "7.0.0", + "css": "6.0.0", + "css-class-name-extractor": "0.0.4", + "css-frameworks": "1.1.0", + "csv-stream": "2.3.0", + "currency": "0.1.0", + "data-mvc": "0.0.2", + "datetime": "6.1.0", + "datetime-parsing": "0.2.0", + "debounce": "0.1.0", + "debug": "6.0.2", + "decimals": "7.1.0", + "default-values": "1.0.1", + "deku": "0.9.23", + "deno": "0.0.5", + "dissect": "1.0.0", + "distributive": "6.0.0", + "dodo-printer": "2.2.3", + "dom-filereader": "7.0.0", + "dom-indexed": "13.0.0", + "dom-simple": "0.4.0", + "dotenv": "4.0.3", + "droplet": "0.6.0", + "dts": "1.0.0", + "dual-numbers": "1.0.3", + "dynamic-buffer": "3.0.1", + "echarts-simple": "0.0.1", + "effect": "4.0.0", + "either": "6.1.0", + "elmish": "0.13.0", + "elmish-enzyme": "0.1.1", + "elmish-hooks": "0.11.0", + "elmish-html": "0.10.0", + "elmish-testing-library": "0.3.2", + "elmish-time-machine": "0.4.2", + "email-validate": "7.0.0", + "encoding": "0.0.9", + "enums": "6.0.1", + "env-names": "0.4.0", + "environment": "2.0.0", + "error": "2.0.0", + "eta-conversion": "0.3.2", + "exceptions": "6.1.0", + "exists": "6.0.0", + "exitcodes": "4.0.0", + "expect-inferred": "3.0.0", + "express": "0.9.1", + "ezfetch": "1.1.1", + "fahrtwind": "2.0.0", + "faker-ffi": "0.1.1", + "fakerjs": "0.0.1", + "fallback": "0.1.0", + "fast-vect": "1.2.0", + "fetch": "4.1.0", + "fetch-argonaut": "1.0.1", + "fetch-core": "5.1.0", + "fetch-yoga-json": "1.1.0", + "ffi-simple": "0.5.1", + "fft": "0.3.0", + "fft-js": "0.1.0", + "filterable": "5.0.0", + "fix-functor": "0.1.0", + "fixed-points": "7.0.0", + "fixed-precision": "5.0.0", + "flame": "1.6.0", + "float32": "2.0.0", + "fmt": "0.2.1", + "foldable-traversable": "6.0.0", + "foldable-traversable-extra": "0.0.6", + "foreign": "7.0.0", + "foreign-object": "4.1.0", + "foreign-readwrite": "3.4.0", + "forgetmenot": "0.1.0", + "fork": "6.0.0", + "form-urlencoded": "7.0.0", + "formatters": "7.0.0", + "framer-motion": "3.1.0", + "free": "7.1.0", + "freeap": "7.0.0", + "freer-free": "0.0.1", + "freet": "7.0.0", + "functions": "6.0.0", + "functor1": "3.0.0", + "functors": "5.0.0", + "fuzzy": "0.4.0", + "gen": "4.0.0", + "generate-values": "1.0.1", + "generic-router": "0.0.1", + "geojson": "0.0.5", + "geometria": "2.2.0", + "gesso": "1.0.1", + "gojs": "0.1.1", + "golden-test": "0.1.0", + "golem-fetch": "0.1.0", + "grain": "3.0.0", + "grain-router": "3.0.0", + "grain-virtualized": "3.0.0", + "graphql-client": "10.1.1", + "graphs": "8.1.0", + "group": "4.1.1", + "halogen": "7.0.0", + "halogen-bootstrap5": "5.3.2", + "halogen-canvas": "1.0.0", + "halogen-css": "10.0.0", + "halogen-declarative-canvas": "0.0.8", + "halogen-echarts-simple": "0.0.4", + "halogen-formless": "4.0.3", + "halogen-helix": "1.1.0", + "halogen-hooks": "0.6.3", + "halogen-hooks-extra": "0.9.0", + "halogen-infinite-scroll": "1.1.0", + "halogen-store": "0.5.4", + "halogen-storybook": "2.0.0", + "halogen-subscriptions": "2.0.0", + "halogen-svg-elems": "8.0.0", + "halogen-typewriter": "1.0.4", + "halogen-use-trigger-hooks": "1.0.0", + "halogen-vdom": "8.0.0", + "halogen-vdom-string-renderer": "0.5.0", + "halogen-xterm": "2.0.0", + "heckin": "2.0.1", + "heterogeneous": "0.7.0", + "homogeneous": "0.4.0", + "http-methods": "6.0.0", + "httpurple": "4.0.0", + "huffman": "0.4.0", + "humdrum": "0.0.1", + "hylograph-canvas": "0.1.0", + "hylograph-d3-kernel": "0.1.0", + "hylograph-graph": "0.3.0", + "hylograph-layout": "0.3.0", + "hylograph-music": "0.4.0", + "hylograph-optics": "0.1.0", + "hylograph-selection": "0.5.1", + "hylograph-simulation": "0.5.0", + "hylograph-simulation-core": "0.1.0", + "hylograph-simulation-halogen": "0.5.0", + "hylograph-transitions": "0.1.0", + "hylograph-wasm-kernel": "0.1.0", + "hyrule": "2.3.8", + "i18next": "0.1.0", + "idb": "0.1.0", + "identity": "6.0.0", + "identy": "4.0.1", + "indexed-db": "1.0.0", + "indexed-monad": "3.0.0", + "ink": "0.0.1", + "int64": "3.0.0", + "integers": "6.0.0", + "interpolate": "5.0.2", + "intersection-observer": "1.0.1", + "invariant": "6.0.0", + "jarilo": "1.0.1", + "jelly": "0.10.0", + "jelly-router": "0.3.0", + "jelly-signal": "0.4.0", + "jest": "1.0.0", + "js-abort-controller": "1.0.0", + "js-bigints": "2.2.1", + "js-date": "8.0.0", + "js-fetch": "0.2.1", + "js-fileio": "3.0.0", + "js-intl": "1.3.0", + "js-iterators": "0.1.1", + "js-maps": "0.1.2", + "js-promise": "1.0.0", + "js-promise-aff": "1.0.0", + "js-temporal": "2.1.0", + "js-timers": "6.1.0", + "js-uri": "3.1.0", + "jsdom": "1.0.0", + "jsinc": "0.5.1", + "json": "1.1.0", + "json-codecs": "5.0.0", + "justifill": "0.5.0", + "jwt": "0.0.9", + "labeled-data": "0.2.0", + "language-cst-parser": "0.14.1", + "lazy": "6.0.0", + "lazy-joe": "1.0.0", + "lcg": "4.0.0", + "leibniz": "5.0.0", + "leveldb": "1.0.1", + "liminal": "1.0.1", + "linalg": "6.0.0", + "linear": "0.1.0", + "lists": "7.0.0", + "literals": "1.0.2", + "logging": "3.0.0", + "logging-journald": "0.4.0", + "lumi-components": "18.0.0", + "machines": "7.0.0", + "maps-eager": "0.5.0", + "marionette": "1.0.0", + "marionette-react-basic-hooks": "0.1.1", + "markdown-it-js": "0.1.0", + "marked": "0.1.0", + "matrices": "5.0.1", + "matryoshka": "1.0.0", + "maybe": "6.0.0", + "media-types": "6.0.0", + "meowclient": "1.0.0", + "midi": "4.0.0", + "milkdown": "0.1.0", + "milkis": "9.0.0", + "mimetype": "0.0.1", + "minibench": "4.0.1", + "mmorph": "7.0.0", + "monad-control": "5.0.0", + "monad-logger": "1.3.1", + "monad-loops": "0.5.0", + "monad-unlift": "1.0.1", + "monoid-extras": "0.0.1", + "monoidal": "0.16.0", + "morello": "0.4.0", + "mote": "3.0.0", + "motsunabe": "2.0.0", + "mvc": "0.0.1", + "mysql": "6.0.1", + "n3": "0.1.0", + "nano-id": "1.1.0", + "nanoid": "0.1.0", + "naturals": "3.0.0", + "nested-functor": "0.2.1", + "newtype": "5.0.0", + "next-purs-rsc": "0.1.0", + "nextjs": "0.1.1", + "nextui": "0.2.0", + "node-buffer": "9.0.0", + "node-child-process": "11.1.0", + "node-event-emitter": "3.0.0", + "node-execa": "5.0.0", + "node-fs": "9.2.0", + "node-glob-basic": "2.0.0", + "node-http": "9.1.0", + "node-http2": "1.1.1", + "node-human-signals": "1.0.0", + "node-net": "5.1.0", + "node-os": "5.1.0", + "node-path": "5.0.1", + "node-process": "11.2.0", + "node-readline": "8.1.1", + "node-sqlite3": "8.0.0", + "node-stream-pipes": "2.1.6", + "node-streams": "9.0.1", + "node-tls": "0.3.1", + "node-url": "7.0.1", + "node-workerbees": "0.3.1", + "node-zlib": "0.4.0", + "nonempty": "7.0.0", + "now": "6.0.0", + "npm-package-json": "2.0.0", + "nqueens": "0.1.4", + "nullable": "6.0.0", + "numberfield": "0.2.2", + "numbers": "9.0.1", + "oak": "3.1.1", + "oak-debug": "1.2.2", + "object-maps": "0.3.0", + "ocarina": "1.5.4", + "oooooooooorrrrrrrmm-lib": "0.0.1", + "open-colors-scales-and-schemes": "1.0.0", + "open-drawing": "6.0.4", + "open-folds": "6.4.0", + "open-foreign-generic": "11.0.3", + "open-memoize": "6.2.0", + "open-mkdirp-aff": "1.2.0", + "open-pairing": "6.2.0", + "open-smolder": "12.0.2", + "options": "7.0.0", + "optparse": "5.0.1", + "ordered-collections": "3.2.0", + "ordered-set": "0.5.0", + "orders": "6.0.0", + "org-doc": "0.1.0", + "owoify": "1.2.0", + "pairs": "9.0.1", + "parallel": "7.0.0", + "parsing": "11.0.0", + "parsing-dataview": "3.2.4", + "partial": "4.0.0", + "pathy": "9.0.0", + "pha": "0.13.0", + "phaser": "0.7.0", + "phylio": "1.1.2", + "pipes": "8.0.0", + "pirates-charm": "0.0.1", + "play": "1.0.0", + "pmock": "0.9.0", + "point-free": "1.0.0", + "pointed-list": "0.5.1", + "polymorphic-vectors": "4.0.0", + "posix-types": "6.0.0", + "postgresql": "2.0.21", + "precise": "6.0.0", + "precise-datetime": "7.0.0", + "prelude": "6.0.2", + "prettier-printer": "3.0.0", + "printf": "0.1.0", + "priority-queue": "0.1.2", + "profunctor": "6.0.1", + "profunctor-lenses": "8.0.0", + "prospero": "0.1.0", + "protobuf": "4.4.0", + "psa-utils": "8.0.0", + "psci-support": "6.0.0", + "punycode": "1.0.0", + "pursfmt": "0.17.0", + "qualified-do": "2.2.0", + "quantities": "12.2.0", + "quickcheck": "8.0.1", + "quickcheck-combinators": "0.1.3", + "quickcheck-laws": "7.0.0", + "quickcheck-utf8": "0.0.0", + "random": "6.0.0", + "rationals": "6.0.0", + "rdf": "0.1.0", + "react": "11.0.0", + "react-aria": "0.2.0", + "react-basic": "17.0.0", + "react-basic-classic": "3.0.0", + "react-basic-dnd": "10.1.0", + "react-basic-dom": "7.0.0", + "react-basic-dom-beta": "0.1.1", + "react-basic-emotion": "7.1.0", + "react-basic-hooks": "9.1.1", + "react-basic-storybook": "2.0.0", + "react-dnd-kit": "0.6.0", + "react-dom": "8.0.0", + "react-halo": "3.0.0", + "react-icons": "1.1.5", + "react-markdown": "0.1.0", + "react-testing-library": "4.0.1", + "react-virtuoso": "1.0.0", + "reactix": "0.6.1", + "read": "1.0.1", + "recharts": "1.1.0", + "record": "4.0.0", + "record-extra": "5.0.1", + "record-extra-srghma": "0.2.8", + "record-ptional-fields": "0.1.2", + "record-studio": "1.0.4", + "ref-lifted": "0.0.2", + "refs": "6.0.0", + "remotedata": "5.0.1", + "repr": "0.5.0", + "resize-arrays": "0.0.1", + "resize-observer": "1.0.0", + "resource": "2.0.1", + "resourcet": "1.0.0", + "result": "1.0.3", + "return": "0.2.0", + "ring-modules": "5.0.1", + "rito": "0.3.4", + "roman": "0.4.0", + "rough-notation": "1.0.2", + "routing": "11.0.0", + "routing-duplex": "0.7.0", + "run": "5.0.0", + "safe-coerce": "2.0.0", + "safely": "4.0.1", + "school-of-music": "1.3.0", + "selection-foldable": "0.2.0", + "selective-functors": "1.0.1", + "semirings": "7.0.0", + "shuffle": "2.0.0", + "sigil": "0.3.0", + "sigil-hats": "0.3.0", + "signal": "13.0.0", + "simple-emitter": "3.0.1", + "simple-i18n": "2.0.1", + "simple-json": "9.0.0", + "simple-json-generics": "0.2.1", + "simple-ulid": "3.0.0", + "sized-matrices": "1.0.0", + "sized-vectors": "5.0.2", + "slug": "3.2.0", + "small-ffi": "4.0.1", + "soundfonts": "4.1.0", + "sparse-matrices": "2.0.1", + "sparse-polynomials": "3.0.1", + "spec": "8.1.2", + "spec-discovery": "8.4.1", + "spec-mocha": "5.1.1", + "spec-node": "0.0.3", + "spec-quickcheck": "5.0.2", + "spec-reporter-xunit": "0.7.1", + "splitmix": "2.1.0", + "ssrs": "1.0.0", + "st": "6.2.0", + "statistics": "0.3.2", + "strictlypositiveint": "1.0.1", + "string-parsers": "8.0.0", + "strings": "6.0.1", + "strings-extra": "4.0.0", + "stringutils": "0.0.12", + "structured-logging": "1.0.0", + "substitute": "0.2.3", + "supply": "0.2.0", + "svg-parser": "3.0.0", + "systemd-journald": "0.3.0", + "tagged": "4.0.2", + "tailrec": "6.1.0", + "tanstack-query": "2.0.1", + "tecton": "0.2.1", + "tecton-halogen": "0.2.0", + "temporal": "0.1.0", + "test-unit": "17.0.0", + "text-formatting": "0.1.0", + "thermite": "6.3.1", + "thermite-dom": "0.3.1", + "these": "6.0.0", + "threading": "0.0.3", + "tidy": "0.11.1", + "tidy-codegen": "4.0.1", + "tldr": "0.0.0", + "toestand": "0.9.0", + "transformation-matrix": "1.0.1", + "transformers": "6.1.0", + "transit": "1.0.0", + "tree-rose": "4.0.2", + "trivial-unfold": "0.5.0", + "ts-bridge": "4.0.0", + "tuples": "7.0.0", + "two-or-more": "1.0.0", + "type-equality": "4.0.1", + "typedenv": "2.0.1", + "typelevel": "6.0.0", + "typelevel-lists": "2.1.0", + "typelevel-peano": "1.0.1", + "typelevel-prelude": "7.0.0", + "typelevel-regex": "0.0.3", + "typelevel-rows": "0.1.0", + "typisch": "0.4.0", + "uint": "7.0.0", + "ulid": "3.0.1", + "uncurried-transformers": "1.1.0", + "undefined": "2.0.0", + "undefined-is-not-a-problem": "1.1.0", + "unfoldable": "6.0.0", + "unicode": "6.0.0", + "unique": "0.6.1", + "unlift": "1.0.1", + "unordered-collections": "3.1.0", + "unsafe-coerce": "6.0.0", + "unsafe-reference": "5.0.0", + "untagged-to-tagged": "0.1.4", + "untagged-union": "1.0.0", + "uri": "9.0.0", + "url-immutable": "1.0.0", + "url-regex-safe": "0.1.1", + "uuid": "9.0.0", + "uuidv4": "1.0.0", + "validation": "6.0.0", + "variant": "8.0.0", + "variant-encodings": "2.0.0", + "variant-gen": "1.0.0", + "vectorfield": "1.0.1", + "vectors": "2.1.0", + "versions": "7.0.0", + "visx": "0.0.2", + "vitest": "2.0.0", + "web-chain": "1.0.1", + "web-clipboard": "6.0.0", + "web-cssom": "2.0.0", + "web-cssom-view": "0.1.0", + "web-dom": "6.0.0", + "web-dom-parser": "8.0.0", + "web-dom-xpath": "3.0.0", + "web-encoding": "3.0.0", + "web-events": "4.0.0", + "web-fetch": "4.0.1", + "web-file": "4.0.0", + "web-geometry": "0.1.0", + "web-html": "4.1.1", + "web-pointerevents": "2.0.0", + "web-proletarian": "1.0.0", + "web-promise": "3.2.0", + "web-resize-observer": "2.1.0", + "web-router": "1.0.0", + "web-socket": "4.0.0", + "web-storage": "5.0.0", + "web-streams": "4.0.0", + "web-touchevents": "4.0.0", + "web-uievents": "5.0.0", + "web-url": "2.0.0", + "web-workers": "1.1.0", + "web-xhr": "5.0.1", + "webb-aff-list": "0.0.2", + "webb-array": "0.0.2", + "webb-channel": "0.0.3", + "webb-commandline": "0.0.3", + "webb-directory": "0.0.7", + "webb-file": "0.0.1", + "webb-map": "0.0.1", + "webb-monad": "0.0.2", + "webb-mutex": "0.0.7", + "webb-parsing": "0.0.5", + "webb-random": "0.0.1", + "webb-refer": "0.0.3", + "webb-set": "0.0.1", + "webb-slot": "0.0.1", + "webb-stateful": "0.0.3", + "webb-string": "0.0.1", + "webb-test": "0.0.1", + "webb-thread": "0.0.2", + "webb-writer": "0.0.3", + "webextension-polyfill": "0.1.0", + "webgpu": "0.0.1", + "which": "2.0.0", + "whine-core": "0.0.33", + "xterm": "1.0.0", + "yaml-next": "3.1.1", + "yoga-acp-om": "0.1.1", + "yoga-better-auth": "0.2.2", + "yoga-bun-yoga": "0.2.1", + "yoga-config": "0.2.1", + "yoga-country": "3.0.0", + "yoga-docker-compose": "0.1.1", + "yoga-dynamodb": "0.1.1", + "yoga-elasticsearch": "0.1.1", + "yoga-fastify": "0.5.1", + "yoga-fastify-om": "0.4.4", + "yoga-fetch": "1.0.1", + "yoga-fetch-om": "0.6.2", + "yoga-http-api": "0.3.1", + "yoga-jaeger": "0.1.1", + "yoga-json": "5.2.1", + "yoga-language": "1.0.0", + "yoga-next-fastify": "0.1.1", + "yoga-om": "2.2.0", + "yoga-om-layer": "2.1.0", + "yoga-om-strom": "0.4.2", + "yoga-om-workerbees": "0.1.2", + "yoga-opentelemetry": "0.2.0", + "yoga-options": "0.1.1", + "yoga-pino": "0.1.1", + "yoga-postgres": "6.0.0", + "yoga-react": "1.0.2", + "yoga-react-dom": "2.0.1", + "yoga-react-native": "0.2.1", + "yoga-react-om": "0.2.0", + "yoga-redis": "0.1.0", + "yoga-shadcn": "1.0.5", + "yoga-sql-types": "0.1.0", + "yoga-sqlite": "0.3.0", + "yoga-sse": "0.1.1", + "yoga-subtlecrypto": "0.1.0", + "yoga-test-docker": "0.1.2", + "yoga-timezone": "1.0.0", + "yoga-tree": "1.0.0", + "yoga-tree-svg": "0.1.0", + "yoga-tree-utils": "1.0.0", + "z3": "0.0.2", + "zipperarray": "2.0.0" + } + }, + "extra_packages": {} + }, + "packages": { + "aff": { + "type": "registry", + "version": "8.0.0", + "integrity": "sha256-9BRIrSdRoYybfiUmBqgKJ66cPqxYHb277bVWQMn/9sg=", + "dependencies": [ + "control", + "datetime", + "effect", + "either", + "exceptions", + "functions", + "newtype", + "parallel", + "partial", + "prelude", + "st", + "tailrec", + "transformers", + "unsafe-coerce" + ] + }, + "affjax": { + "type": "registry", + "version": "13.0.0", + "integrity": "sha256-dpOV8ELNqUVBwxE6eKFdnFky+sLwPFANrFUzIxQablk=", + "dependencies": [ + "aff", + "argonaut-core", + "arraybuffer-types", + "arrays", + "control", + "datetime", + "either", + "exceptions", + "foldable-traversable", + "foreign", + "form-urlencoded", + "functions", + "http-methods", + "lists", + "maybe", + "media-types", + "newtype", + "nullable", + "prelude", + "transformers", + "web-dom", + "web-file", + "web-xhr" + ] + }, + "affjax-web": { + "type": "registry", + "version": "1.0.0", + "integrity": "sha256-sHg+G50LDoZYG6UHvUiH1ZLT/UHIXaEF5DUBaPXxtxM=", + "dependencies": [ + "aff", + "affjax", + "either", + "maybe", + "prelude" + ] + }, + "argonaut": { + "type": "registry", + "version": "9.0.0", + "integrity": "sha256-M/EUEihkRu5xKGKe7UfkrybGj56SDTKz1EappEOiWgA=", + "dependencies": [ + "argonaut-codecs", + "argonaut-core", + "argonaut-traversals" + ] + }, + "argonaut-codecs": { + "type": "registry", + "version": "9.1.0", + "integrity": "sha256-K910SBrmYallESsm8pxJYThs7lig6hxX3M4PW33Cgew=", + "dependencies": [ + "argonaut-core", + "arrays", + "bifunctors", + "either", + "foldable-traversable", + "foreign-object", + "identity", + "integers", + "lists", + "maybe", + "nonempty", + "ordered-collections", + "prelude", + "record", + "strings", + "tuples" + ] + }, + "argonaut-core": { + "type": "registry", + "version": "7.0.0", + "integrity": "sha256-gYZihtBIKthVuXXoiPphMookSf8M5aHyFYkcIWqlxTM=", + "dependencies": [ + "arrays", + "control", + "either", + "foreign-object", + "functions", + "gen", + "maybe", + "nonempty", + "prelude", + "strings", + "tailrec" + ] + }, + "argonaut-traversals": { + "type": "registry", + "version": "10.0.0", + "integrity": "sha256-guTCBwDiOECrV9CvHF2cH61wa0xMNkbw2/zgl9c0QVk=", + "dependencies": [ + "argonaut-codecs", + "argonaut-core", + "arrays", + "control", + "either", + "foldable-traversable", + "foreign-object", + "gen", + "integers", + "lists", + "maybe", + "prelude", + "profunctor-lenses", + "strings", + "tailrec", + "tuples", + "unfoldable" + ] + }, + "arraybuffer-types": { + "type": "registry", + "version": "3.0.2", + "integrity": "sha256-p05cJnSkyeoB7VHzMyc2Eb1RwUaB7Nl0sQSqEGNEUD8=", + "dependencies": [] + }, + "arrays": { + "type": "registry", + "version": "7.3.0", + "integrity": "sha256-GD4z7LCi9wzi7FCQqbWhvs8paoUK9NO+HwgXZ+KP8Rk=", + "dependencies": [ + "bifunctors", + "control", + "foldable-traversable", + "functions", + "maybe", + "nonempty", + "partial", + "prelude", + "safe-coerce", + "st", + "tailrec", + "tuples", + "unfoldable", + "unsafe-coerce" + ] + }, + "bifunctors": { + "type": "registry", + "version": "6.1.0", + "integrity": "sha256-CvZRb8uI75eKIqiYntR+k05Wk6tKlMS5w8sCA2AFna0=", + "dependencies": [ + "const", + "either", + "newtype", + "prelude", + "tuples" + ] + }, + "catenable-lists": { + "type": "registry", + "version": "7.0.0", + "integrity": "sha256-7+i/MHBLIRh604iCNT4ENWRWE/wuHIotER4kpdX9Ve0=", + "dependencies": [ + "control", + "foldable-traversable", + "lists", + "maybe", + "prelude", + "tuples", + "unfoldable" + ] + }, + "console": { + "type": "registry", + "version": "6.1.0", + "integrity": "sha256-3T5PPz1mATZmV0C4GzkCkpLlCa5xG7u8ZNgnLrrrI1Q=", + "dependencies": [ + "effect", + "prelude" + ] + }, + "const": { + "type": "registry", + "version": "6.0.0", + "integrity": "sha256-S+m0lQsxnji0z1XRUmkn+3RFuWJ5CXWuIr1u3i8Bfoc=", + "dependencies": [ + "invariant", + "newtype", + "prelude" + ] + }, + "contravariant": { + "type": "registry", + "version": "6.0.0", + "integrity": "sha256-nuckEWqL80NaPmR/l9AUyrfycNh18XcvlZV9PAamwqc=", + "dependencies": [ + "const", + "either", + "newtype", + "prelude", + "tuples" + ] + }, + "control": { + "type": "registry", + "version": "6.0.0", + "integrity": "sha256-0vxyYh5FL+ilxgLo954w1LqCUC0RSIivNNlb9CaNUQg=", + "dependencies": [ + "newtype", + "prelude" + ] + }, + "datetime": { + "type": "registry", + "version": "6.1.0", + "integrity": "sha256-tbv6eDXy6QOD7YSknxYSbsDF32OC2JrNbSBpzMs7Doc=", + "dependencies": [ + "bifunctors", + "control", + "either", + "enums", + "foldable-traversable", + "functions", + "gen", + "integers", + "lists", + "maybe", + "newtype", + "numbers", + "ordered-collections", + "partial", + "prelude", + "tuples" + ] + }, + "distributive": { + "type": "registry", + "version": "6.0.0", + "integrity": "sha256-netyJK4B32PDYHD0tF2AenKgo3urzbI770ycM0pArAo=", + "dependencies": [ + "identity", + "newtype", + "prelude", + "tuples", + "type-equality" + ] + }, + "dom-indexed": { + "type": "registry", + "version": "13.0.0", + "integrity": "sha256-Tr9uDQdmKN4TUGMg1/4KzsVu5zPa8OqKB4N6/S2a8vU=", + "dependencies": [ + "datetime", + "media-types", + "prelude", + "strings", + "web-clipboard", + "web-events", + "web-html", + "web-pointerevents", + "web-touchevents", + "web-uievents" + ] + }, + "effect": { + "type": "registry", + "version": "4.0.0", + "integrity": "sha256-DdqU3bncRTjgMVPPRlAuGHVeajSPEcRaRhNuio7if6s=", + "dependencies": [ + "prelude" + ] + }, + "either": { + "type": "registry", + "version": "6.1.0", + "integrity": "sha256-tBHx3PgtH4GZOApZCDkcM7szRTYJinrUrVGWazQCUUg=", + "dependencies": [ + "control", + "invariant", + "maybe", + "prelude" + ] + }, + "enums": { + "type": "registry", + "version": "6.0.1", + "integrity": "sha256-sdZOmLX5+5pASGpvVyT0vSD5BBYQjZiayjV5XiPutso=", + "dependencies": [ + "control", + "either", + "gen", + "maybe", + "newtype", + "nonempty", + "partial", + "prelude", + "tuples", + "unfoldable" + ] + }, + "exceptions": { + "type": "registry", + "version": "6.1.0", + "integrity": "sha256-nRrr+N0wk0TUFOWLR7e1n1oxcXo/X345bQUtoY9lW6A=", + "dependencies": [ + "effect", + "either", + "maybe", + "prelude" + ] + }, + "exists": { + "type": "registry", + "version": "6.0.0", + "integrity": "sha256-vtLbrWNaI+pzx/2fw2AQnCovoLtcosClIhjO26QKkh8=", + "dependencies": [ + "unsafe-coerce" + ] + }, + "fetch": { + "type": "registry", + "version": "4.1.0", + "integrity": "sha256-AY3VFeK1Y0Mc6Gsr0DCXlbAn4MnhCXFW48EJW8XMdDI=", + "dependencies": [ + "aff", + "arraybuffer-types", + "bifunctors", + "effect", + "either", + "foreign", + "http-methods", + "js-fetch", + "js-promise", + "js-promise-aff", + "maybe", + "newtype", + "ordered-collections", + "prelude", + "record", + "strings", + "typelevel-prelude", + "web-file", + "web-streams" + ] + }, + "fetch-argonaut": { + "type": "registry", + "version": "1.0.1", + "integrity": "sha256-Gn9CCRtTpar9jKJuDyPODFnPA9NE6bhwt/HkcDL/bs0=", + "dependencies": [ + "aff", + "argonaut", + "either", + "foreign", + "prelude", + "transformers", + "unsafe-coerce" + ] + }, + "foldable-traversable": { + "type": "registry", + "version": "6.0.0", + "integrity": "sha256-NGGWyCio/xjce6fPP7y3wwg7CheqllID6aJudDxbWhA=", + "dependencies": [ + "bifunctors", + "const", + "control", + "either", + "functors", + "identity", + "maybe", + "newtype", + "orders", + "prelude", + "tuples" + ] + }, + "foreign": { + "type": "registry", + "version": "7.0.0", + "integrity": "sha256-jiDRMVQfovGsbbA/FzbzYf6iWk9i2b5gNrIjz+gFfsI=", + "dependencies": [ + "either", + "functions", + "integers", + "lists", + "maybe", + "prelude", + "strings", + "transformers", + "unsafe-coerce" + ] + }, + "foreign-object": { + "type": "registry", + "version": "4.1.0", + "integrity": "sha256-x/Q7r80z/vmHRKvPwhYmHP/DnJciPtsyGfRTMzayKIU=", + "dependencies": [ + "arrays", + "foldable-traversable", + "functions", + "gen", + "lists", + "maybe", + "prelude", + "st", + "tailrec", + "tuples", + "typelevel-prelude", + "unfoldable", + "unsafe-coerce" + ] + }, + "fork": { + "type": "registry", + "version": "6.0.0", + "integrity": "sha256-0keBVeZZg1eCZEy/En1CohVW829gQUqKtqBNIRa93/A=", + "dependencies": [ + "aff", + "prelude", + "transformers" + ] + }, + "form-urlencoded": { + "type": "registry", + "version": "7.0.0", + "integrity": "sha256-+8F0S6thSLsC1GfQiTdTt6jyTMuhsgKLciBUzde9aFU=", + "dependencies": [ + "foldable-traversable", + "js-uri", + "maybe", + "newtype", + "prelude", + "strings", + "tuples" + ] + }, + "free": { + "type": "registry", + "version": "7.1.0", + "integrity": "sha256-tUBInfUpRQEw4u94GWcYW7EYdmSdDHSRn88a+C0eltU=", + "dependencies": [ + "catenable-lists", + "control", + "distributive", + "either", + "exists", + "foldable-traversable", + "invariant", + "lazy", + "maybe", + "prelude", + "tailrec", + "transformers", + "tuples", + "unsafe-coerce" + ] + }, + "freeap": { + "type": "registry", + "version": "7.0.0", + "integrity": "sha256-e7SiV9rg+rVARsU9cxA/02sps7ubogvVgYwj8nWosX4=", + "dependencies": [ + "const", + "either", + "gen", + "lists", + "newtype", + "nonempty", + "prelude", + "tailrec", + "tuples", + "unsafe-coerce" + ] + }, + "functions": { + "type": "registry", + "version": "6.0.0", + "integrity": "sha256-0jgtOr+SFO4UScKc40oA/9Vdcf2rLn3h3vZlY0KfqVo=", + "dependencies": [ + "prelude" + ] + }, + "functors": { + "type": "registry", + "version": "5.0.0", + "integrity": "sha256-W1o/4wcp6S3chodYL8uixlXK2mfr1L+SzM1R+whqUco=", + "dependencies": [ + "bifunctors", + "const", + "contravariant", + "control", + "distributive", + "either", + "invariant", + "maybe", + "newtype", + "prelude", + "profunctor", + "tuples", + "unsafe-coerce" + ] + }, + "gen": { + "type": "registry", + "version": "4.0.0", + "integrity": "sha256-kyOateeOtQa07vfTMYeSuCKdelgKE1R65fwKkZ10wsY=", + "dependencies": [ + "either", + "foldable-traversable", + "identity", + "maybe", + "newtype", + "nonempty", + "prelude", + "tailrec", + "tuples", + "unfoldable" + ] + }, + "halogen": { + "type": "registry", + "version": "7.0.0", + "integrity": "sha256-YrUm27QEiE1DyGaGJ5MNLVg4H/P3gnX/NVv1gRtWjB4=", + "dependencies": [ + "aff", + "bifunctors", + "console", + "control", + "dom-indexed", + "effect", + "either", + "exceptions", + "foldable-traversable", + "foreign", + "fork", + "free", + "freeap", + "functions", + "halogen-subscriptions", + "halogen-vdom", + "lazy", + "lists", + "maybe", + "media-types", + "newtype", + "ordered-collections", + "parallel", + "prelude", + "profunctor", + "refs", + "strings", + "tailrec", + "transformers", + "tuples", + "unfoldable", + "unsafe-coerce", + "unsafe-reference", + "web-clipboard", + "web-dom", + "web-events", + "web-file", + "web-html", + "web-touchevents", + "web-uievents" + ] + }, + "halogen-subscriptions": { + "type": "registry", + "version": "2.0.0", + "integrity": "sha256-1eBtVZENgGtKuOY9H0iuYD3dO1CSqmOIyhYe4OhypOU=", + "dependencies": [ + "arrays", + "contravariant", + "control", + "effect", + "foldable-traversable", + "maybe", + "prelude", + "refs", + "safe-coerce", + "unsafe-reference" + ] + }, + "halogen-vdom": { + "type": "registry", + "version": "8.0.0", + "integrity": "sha256-jk6aj/tH630skFVk6mB5Q+0g2zb897KX72T63fJhJ2Y=", + "dependencies": [ + "arrays", + "bifunctors", + "effect", + "foreign", + "foreign-object", + "functions", + "maybe", + "newtype", + "nullable", + "prelude", + "refs", + "tuples", + "unsafe-coerce", + "web-dom", + "web-events" + ] + }, + "http-methods": { + "type": "registry", + "version": "6.0.0", + "integrity": "sha256-S0UjrJSgwJxEsFHM/pX77dM8V4HfvvDqdHPckQrtxMs=", + "dependencies": [ + "either", + "prelude", + "strings" + ] + }, + "identity": { + "type": "registry", + "version": "6.0.0", + "integrity": "sha256-RY/iXPpxpqvKHYfJeFVNI1J06kohB9rbtWYuha8t0LE=", + "dependencies": [ + "control", + "invariant", + "newtype", + "prelude" + ] + }, + "integers": { + "type": "registry", + "version": "6.0.0", + "integrity": "sha256-Y8yozC1uHRLcruobEyv5/bMKzE1BoKJ/olHv6o+bgm8=", + "dependencies": [ + "maybe", + "numbers", + "prelude" + ] + }, + "invariant": { + "type": "registry", + "version": "6.0.0", + "integrity": "sha256-HyoT1I5qIHRq8RSZji6zZDvqnpVjLw748utrKwZtqsw=", + "dependencies": [ + "control", + "prelude" + ] + }, + "js-date": { + "type": "registry", + "version": "8.0.0", + "integrity": "sha256-DNQrp4xYc8o80jtxo0aOkUeXGi6h0Xidkk2ZQ8Y4l5Y=", + "dependencies": [ + "datetime", + "effect", + "enums", + "foreign", + "functions", + "integers", + "maybe", + "prelude" + ] + }, + "js-fetch": { + "type": "registry", + "version": "0.2.1", + "integrity": "sha256-jsMrMh8kIUW5bHbHl7hy8ev/iMru1MzfDa+hoAxo14Q=", + "dependencies": [ + "arraybuffer-types", + "arrays", + "effect", + "foldable-traversable", + "foreign", + "foreign-object", + "functions", + "http-methods", + "js-promise", + "maybe", + "newtype", + "prelude", + "record", + "tuples", + "typelevel-prelude", + "unfoldable", + "web-file", + "web-streams" + ] + }, + "js-promise": { + "type": "registry", + "version": "1.0.0", + "integrity": "sha256-u+6+ofIwT5XhT2fbMu4dShwkyWGfPRt9642Cnzw/Wvk=", + "dependencies": [ + "effect", + "exceptions", + "foldable-traversable", + "functions", + "maybe", + "newtype", + "prelude" + ] + }, + "js-promise-aff": { + "type": "registry", + "version": "1.0.0", + "integrity": "sha256-gEKAdNnTWjXS+tTp/AhYl/ezloKfuoQFcBKrdOx2USo=", + "dependencies": [ + "aff", + "control", + "effect", + "either", + "exceptions", + "foreign", + "js-promise", + "maybe", + "prelude", + "transformers" + ] + }, + "js-uri": { + "type": "registry", + "version": "3.1.0", + "integrity": "sha256-7waHOctAfqhhw540FNJ7C/YsDMQW9TmA+SpHhduH6Uw=", + "dependencies": [ + "functions", + "maybe", + "prelude" + ] + }, + "lazy": { + "type": "registry", + "version": "6.0.0", + "integrity": "sha256-MHRC+1Pc+AjortBp4b/e1e6KpEpsDwaJBNBB7TOL+1I=", + "dependencies": [ + "control", + "foldable-traversable", + "invariant", + "prelude" + ] + }, + "lists": { + "type": "registry", + "version": "7.0.0", + "integrity": "sha256-/mGd7wLoU+wsTcqii7SSUByxsvwvjnu2PjwAD47yYb4=", + "dependencies": [ + "bifunctors", + "control", + "foldable-traversable", + "lazy", + "maybe", + "newtype", + "nonempty", + "partial", + "prelude", + "tailrec", + "tuples", + "unfoldable" + ] + }, + "maybe": { + "type": "registry", + "version": "6.0.0", + "integrity": "sha256-8X6XEgtZ5lqfkEuc1hWOofnL6mwhk8gSJTxwvbR/SbY=", + "dependencies": [ + "control", + "invariant", + "newtype", + "prelude" + ] + }, + "media-types": { + "type": "registry", + "version": "6.0.0", + "integrity": "sha256-3Wyq+vfqJFTmy1k6DGVnXStxtZxgK631wusDPzlFVxg=", + "dependencies": [ + "newtype", + "prelude" + ] + }, + "newtype": { + "type": "registry", + "version": "5.0.0", + "integrity": "sha256-dt6w0cty4OS+Pjt9fsh3iU6ottqSU83mdAZkaEEvo6c=", + "dependencies": [ + "prelude", + "safe-coerce" + ] + }, + "node-buffer": { + "type": "registry", + "version": "9.0.0", + "integrity": "sha256-RdQjilmEHqsT4fwpCr8Mavw8KTafYrS3ZM6zeAAVAl8=", + "dependencies": [ + "arraybuffer-types", + "effect", + "functions", + "maybe", + "nullable", + "partial", + "prelude", + "st", + "unsafe-coerce" + ] + }, + "node-event-emitter": { + "type": "registry", + "version": "3.0.0", + "integrity": "sha256-wodx71NxJBPXOaawFn01V1ByYB2vT+I3RuV2giOVKMg=", + "dependencies": [ + "effect", + "either", + "functions", + "maybe", + "nullable", + "prelude", + "unsafe-coerce" + ] + }, + "node-fs": { + "type": "registry", + "version": "9.2.0", + "integrity": "sha256-fxioTSm9y47WuYs9F/7nxCh/RVDCl3Kr3Hg779J4SJA=", + "dependencies": [ + "aff", + "datetime", + "effect", + "either", + "enums", + "exceptions", + "functions", + "integers", + "js-date", + "maybe", + "node-buffer", + "node-path", + "node-streams", + "nullable", + "partial", + "prelude", + "strings" + ] + }, + "node-http": { + "type": "registry", + "version": "9.1.0", + "integrity": "sha256-V6EFML/uqvBEUlQsph0VPuLQCtZdn51HJTa6yxh7YTk=", + "dependencies": [ + "arrays", + "datetime", + "effect", + "exceptions", + "foreign", + "foreign-object", + "maybe", + "node-buffer", + "node-event-emitter", + "node-net", + "node-streams", + "node-tls", + "node-url", + "nullable", + "prelude", + "unsafe-coerce" + ] + }, + "node-net": { + "type": "registry", + "version": "5.1.0", + "integrity": "sha256-e2kxHXolOzYD/OkZekYlVPwCl/b4WqyLOodA6QX7FQI=", + "dependencies": [ + "datetime", + "effect", + "exceptions", + "maybe", + "node-event-emitter", + "node-fs", + "node-streams", + "nullable", + "partial", + "prelude", + "unsafe-coerce" + ] + }, + "node-path": { + "type": "registry", + "version": "5.0.1", + "integrity": "sha256-j7n/SmpVz0FOMJl4XZop3ofJ+MeIPkMNqUEUKHEL6Us=", + "dependencies": [ + "effect" + ] + }, + "node-process": { + "type": "registry", + "version": "11.2.0", + "integrity": "sha256-onzKeNmaeYfN3HSDPqSFTrrP2Yl9tnVsfKKhnf2plxM=", + "dependencies": [ + "effect", + "exceptions", + "foreign", + "foreign-object", + "maybe", + "node-event-emitter", + "node-streams", + "nullable", + "posix-types", + "prelude", + "strings" + ] + }, + "node-streams": { + "type": "registry", + "version": "9.0.1", + "integrity": "sha256-yuVGm/R/tBr4Nphi+a1a984Z0fkmN9Q5wWtSmbmJTpA=", + "dependencies": [ + "aff", + "arrays", + "effect", + "either", + "exceptions", + "maybe", + "node-buffer", + "node-event-emitter", + "nullable", + "prelude", + "refs", + "st", + "tailrec", + "unsafe-coerce" + ] + }, + "node-tls": { + "type": "registry", + "version": "0.3.1", + "integrity": "sha256-TRTFNDfY65UUN5dZO+00qwNc4Ab7cpj2mi7B0VIKq3w=", + "dependencies": [ + "effect", + "either", + "exceptions", + "foreign", + "maybe", + "node-buffer", + "node-event-emitter", + "node-net", + "nullable", + "partial", + "prelude", + "unsafe-coerce" + ] + }, + "node-url": { + "type": "registry", + "version": "7.0.1", + "integrity": "sha256-4h+vxO0RMrLN85hAQnoOjN6qD0jRIODhQcBIJXkat/4=", + "dependencies": [ + "effect", + "foreign", + "functions", + "maybe", + "nullable", + "prelude", + "tuples" + ] + }, + "nonempty": { + "type": "registry", + "version": "7.0.0", + "integrity": "sha256-Ctkq8/+KiGqCE53Y/LvibA/coy7ev9HtRZU+GYS7yfQ=", + "dependencies": [ + "control", + "foldable-traversable", + "maybe", + "prelude", + "tuples", + "unfoldable" + ] + }, + "now": { + "type": "registry", + "version": "6.0.0", + "integrity": "sha256-mTyG4s8tps3XIf0jjV/lME2/QlwMzrQly2wqfSSUM3o=", + "dependencies": [ + "datetime", + "effect", + "prelude" + ] + }, + "nullable": { + "type": "registry", + "version": "6.0.0", + "integrity": "sha256-CDAZk+L9Sc0GCFTkE8n+qdp4Ys8avvnkU+m2hVALt7M=", + "dependencies": [ + "functions", + "maybe", + "prelude" + ] + }, + "numbers": { + "type": "registry", + "version": "9.0.1", + "integrity": "sha256-ZydYckgwRAnRaFC7pu6fADhzdwX+UqHZrfJyet64zls=", + "dependencies": [ + "functions", + "maybe", + "prelude" + ] + }, + "ordered-collections": { + "type": "registry", + "version": "3.2.0", + "integrity": "sha256-NaEXc2cz/7lJfxPWEja4XlZbGxuRqJwFKQJjsJf51kQ=", + "dependencies": [ + "arrays", + "control", + "foldable-traversable", + "functions", + "gen", + "lists", + "maybe", + "newtype", + "partial", + "prelude", + "safe-coerce", + "tailrec", + "tuples", + "unfoldable" + ] + }, + "orders": { + "type": "registry", + "version": "6.0.0", + "integrity": "sha256-nSMxZK7wxyDULkjZqvMyB71mVo6b0AyLLHosDVbv1XM=", + "dependencies": [ + "newtype", + "prelude" + ] + }, + "parallel": { + "type": "registry", + "version": "7.0.0", + "integrity": "sha256-bAGpVhrFz2IyHkA4MR7+tE/19FZ7dzYBdYPgK5svkqw=", + "dependencies": [ + "control", + "effect", + "either", + "foldable-traversable", + "functors", + "maybe", + "newtype", + "prelude", + "profunctor", + "refs", + "transformers" + ] + }, + "partial": { + "type": "registry", + "version": "4.0.0", + "integrity": "sha256-mdrlJBAgFMh79hNRW39uv7c+BwnnaOrAMmdh7+VhEhs=", + "dependencies": [] + }, + "posix-types": { + "type": "registry", + "version": "6.0.0", + "integrity": "sha256-Je5jG439F/SFO/IvVevZnWVxDIonrehlKuJkefP2lHE=", + "dependencies": [ + "maybe", + "newtype", + "prelude" + ] + }, + "prelude": { + "type": "registry", + "version": "6.0.2", + "integrity": "sha256-IqykrDRquGUD/LRSad7y75hZv3bUsGQ4knc8tdez8Qw=", + "dependencies": [] + }, + "profunctor": { + "type": "registry", + "version": "6.0.1", + "integrity": "sha256-Ow0mJC+PIIRq/a3mzCWY5vxuTgR/e+Ee68+mWeSRnWY=", + "dependencies": [ + "control", + "distributive", + "either", + "exists", + "invariant", + "newtype", + "prelude", + "tuples" + ] + }, + "profunctor-lenses": { + "type": "registry", + "version": "8.0.0", + "integrity": "sha256-mVIYR3kEMHyO2m+VicJeZeVyDJ4IgvkMzDll3JdnRN4=", + "dependencies": [ + "arrays", + "bifunctors", + "const", + "control", + "distributive", + "either", + "foldable-traversable", + "foreign-object", + "functors", + "identity", + "lists", + "maybe", + "newtype", + "ordered-collections", + "partial", + "prelude", + "profunctor", + "record", + "safe-coerce", + "transformers", + "tuples" + ] + }, + "record": { + "type": "registry", + "version": "4.0.0", + "integrity": "sha256-WQ8fqp4X1wTA70J4qUcPW4Z7DCwx6e5KJnT/EuTdWSc=", + "dependencies": [ + "functions", + "prelude", + "unsafe-coerce" + ] + }, + "refs": { + "type": "registry", + "version": "6.0.0", + "integrity": "sha256-KKD5G9dR2SauuDGULUJsxjqDOEi1Jqm+2Sq5U2mY8YU=", + "dependencies": [ + "effect", + "prelude" + ] + }, + "safe-coerce": { + "type": "registry", + "version": "2.0.0", + "integrity": "sha256-EJrxtKt5xC7TYjBZrSBO/J7Aw3DmtilYjt4olpAXWVc=", + "dependencies": [ + "unsafe-coerce" + ] + }, + "st": { + "type": "registry", + "version": "6.2.0", + "integrity": "sha256-WI4MEkkwUd4pnwZQ3G8VKWgX5t1RSNJludm5e9boaRo=", + "dependencies": [ + "effect", + "partial", + "prelude", + "tailrec", + "unsafe-coerce" + ] + }, + "strings": { + "type": "registry", + "version": "6.0.1", + "integrity": "sha256-FNto2hoNW5Da6W6crIk77YXMagBvOGHZE1kO7eZ1vLM=", + "dependencies": [ + "arrays", + "control", + "either", + "enums", + "foldable-traversable", + "gen", + "integers", + "maybe", + "newtype", + "nonempty", + "partial", + "prelude", + "tailrec", + "tuples", + "unfoldable", + "unsafe-coerce" + ] + }, + "tailrec": { + "type": "registry", + "version": "6.1.0", + "integrity": "sha256-xrorCfP0xV1wubs27idQCHQqdvSUuqmmL/am7Ji1wVU=", + "dependencies": [ + "bifunctors", + "effect", + "either", + "identity", + "maybe", + "partial", + "prelude", + "refs" + ] + }, + "transformers": { + "type": "registry", + "version": "6.1.0", + "integrity": "sha256-QJmgNT/y7ljPHSsXaW4zxF/982BFiQ90KNL1g/NtEOQ=", + "dependencies": [ + "control", + "distributive", + "effect", + "either", + "exceptions", + "foldable-traversable", + "identity", + "lazy", + "maybe", + "newtype", + "prelude", + "st", + "tailrec", + "tuples", + "unfoldable" + ] + }, + "tuples": { + "type": "registry", + "version": "7.0.0", + "integrity": "sha256-BKy7EfK8S1KeTCwyWqyW1wwcpSAQuh3IsczSVgS6fzY=", + "dependencies": [ + "control", + "invariant", + "prelude" + ] + }, + "type-equality": { + "type": "registry", + "version": "4.0.1", + "integrity": "sha256-BBqYOSnAKmayRvR5mtZoCf4SlYs2ipatD/+5nQx73aw=", + "dependencies": [] + }, + "typelevel-prelude": { + "type": "registry", + "version": "7.0.0", + "integrity": "sha256-+pHi14/40mTj/+lmCWqL0T7iXIXD1+NSg/KItyyoB0A=", + "dependencies": [ + "prelude", + "type-equality" + ] + }, + "unfoldable": { + "type": "registry", + "version": "6.0.0", + "integrity": "sha256-BK/6By1sKp/ztk+9CF3q43SV0QDTLVdsGqFerKHSmbg=", + "dependencies": [ + "foldable-traversable", + "maybe", + "partial", + "prelude", + "tuples" + ] + }, + "unsafe-coerce": { + "type": "registry", + "version": "6.0.0", + "integrity": "sha256-0L1QsaY20OILjfU5TV72d3U/tSjhmL9hJ32WMp857Lk=", + "dependencies": [] + }, + "unsafe-reference": { + "type": "registry", + "version": "5.0.0", + "integrity": "sha256-ttSJTQUnK8AK2eGOsfRxLUJEDPL6pnmExjbiOqfwC6I=", + "dependencies": [ + "prelude" + ] + }, + "web-clipboard": { + "type": "registry", + "version": "6.0.0", + "integrity": "sha256-3CV0txwKddLoj8qBrRjUWbiX0M8+rnOpi1iApr5PEb0=", + "dependencies": [ + "effect", + "functions", + "js-promise", + "maybe", + "nullable", + "prelude", + "unsafe-coerce", + "web-events", + "web-html" + ] + }, + "web-dom": { + "type": "registry", + "version": "6.0.0", + "integrity": "sha256-rVeqkxChkjqisDvOujy9BMEgGqJkwX3t6g21bXfiG5o=", + "dependencies": [ + "effect", + "enums", + "maybe", + "newtype", + "nullable", + "prelude", + "unsafe-coerce", + "web-events" + ] + }, + "web-events": { + "type": "registry", + "version": "4.0.0", + "integrity": "sha256-wZNeldG0OU+8qGycUJ9e6vxf1/GC4Ep4OlRMhvjs9LA=", + "dependencies": [ + "datetime", + "effect", + "enums", + "foreign", + "functions", + "maybe", + "newtype", + "nullable", + "prelude", + "unsafe-coerce" + ] + }, + "web-file": { + "type": "registry", + "version": "4.0.0", + "integrity": "sha256-txsdkeWBKU/+5v9m5c6kysgJQSDyV1r7MHaeT4STpjQ=", + "dependencies": [ + "datetime", + "effect", + "enums", + "foreign", + "integers", + "maybe", + "media-types", + "nullable", + "numbers", + "partial", + "prelude", + "tuples", + "unfoldable", + "unsafe-coerce", + "web-events" + ] + }, + "web-html": { + "type": "registry", + "version": "4.1.1", + "integrity": "sha256-yOuZJGUxFrivHKpo/jKuKzFZaq6PPPE64ftSysXFssk=", + "dependencies": [ + "effect", + "enums", + "foreign", + "functions", + "js-date", + "maybe", + "media-types", + "newtype", + "nullable", + "prelude", + "unsafe-coerce", + "web-dom", + "web-events", + "web-file", + "web-storage" + ] + }, + "web-pointerevents": { + "type": "registry", + "version": "2.0.0", + "integrity": "sha256-mQbm1V36u3It2WdYMAXtzLBJl3uRAhN4BjeZTw3LWN8=", + "dependencies": [ + "effect", + "maybe", + "prelude", + "unsafe-coerce", + "web-dom", + "web-events", + "web-html", + "web-uievents" + ] + }, + "web-storage": { + "type": "registry", + "version": "5.0.0", + "integrity": "sha256-YJfos55oeKtBStNfoWfeflSy8j+4IDym5eaZtIY7YM4=", + "dependencies": [ + "effect", + "maybe", + "nullable", + "prelude", + "unsafe-coerce", + "web-events" + ] + }, + "web-streams": { + "type": "registry", + "version": "4.0.0", + "integrity": "sha256-iKQbHh/3wNOWyGTZmWJZ1Yuri9J/wQIJKXcxrELaHVo=", + "dependencies": [ + "arraybuffer-types", + "effect", + "exceptions", + "js-promise", + "maybe", + "nullable", + "prelude", + "tuples" + ] + }, + "web-touchevents": { + "type": "registry", + "version": "4.0.0", + "integrity": "sha256-H02c53BJlHDlAgogj/x8bM7PagJjhMMrMPYy9I7Rf0s=", + "dependencies": [ + "functions", + "maybe", + "nullable", + "prelude", + "unsafe-coerce", + "web-events", + "web-uievents" + ] + }, + "web-uievents": { + "type": "registry", + "version": "5.0.0", + "integrity": "sha256-yh9uVLIGu8wE1+jwLehtfsRfR1eyiShhIMMhBPqKybE=", + "dependencies": [ + "effect", + "enums", + "maybe", + "nullable", + "prelude", + "unsafe-coerce", + "web-events", + "web-html" + ] + }, + "web-xhr": { + "type": "registry", + "version": "5.0.1", + "integrity": "sha256-gxQgeGwvF/NyGdw0FJ9IzXI3gqIMNb8oQO8X+p5jDyw=", + "dependencies": [ + "arraybuffer-types", + "datetime", + "effect", + "either", + "enums", + "foreign", + "http-methods", + "maybe", + "media-types", + "newtype", + "nullable", + "prelude", + "unsafe-coerce", + "web-dom", + "web-events", + "web-file", + "web-html" + ] + } + } +} diff --git a/spago.yaml b/spago.yaml @@ -0,0 +1,44 @@ +package: + name: scrobbler-purescript + dependencies: + - aff + - affjax + - affjax-web + - argonaut + - argonaut-core + - arrays + - console + - datetime + - effect + - either + - exceptions + - fetch + - fetch-argonaut + - foreign-object + - halogen + - http-methods + - integers + - js-uri + - maybe + - node-buffer + - node-event-emitter + - node-fs + - node-http + - node-net + - node-process + - node-streams + - node-url + - now + - ordered-collections + - prelude + - strings + - tailrec + - unsafe-coerce + - web-dom + test: + main: Test.Main + dependencies: [] +workspace: + packageSet: + registry: 75.9.0 + extraPackages: {} diff --git a/src/Client.purs b/src/Client.purs @@ -0,0 +1,213 @@ +module Client where + +import Prelude + +import Affjax.Web as AX +import Affjax.ResponseFormat as ResponseFormat +import Data.Argonaut (decodeJson) +import Data.Array (mapWithIndex) +import Data.DateTime.Instant (unInstant) +import Data.Either (Either(..)) +import Data.Int (floor) +import Data.Maybe (Maybe(..), fromMaybe) +import Effect (Effect) +import Effect.Console as Console +import Effect.Aff (Aff, delay) +import Effect.Aff.Class (class MonadAff) +import Effect.Class (liftEffect) +import Effect.Now (now) +import Control.Monad.Rec.Class (forever) +import Halogen as H +import Halogen.Aff as HA +import Halogen.HTML as HH +import Halogen.HTML.Events as HE +import Halogen.HTML.Properties as HP +import Halogen.VDom.Driver (runUI) +import JSURI (encodeURIComponent) +import Web.DOM.ParentNode (QuerySelector(..)) +import Types (Listen(..), ListenBrainzResponse(..), TrackMetadata(..), Payload(..), MbidMapping(..)) +import Data.Time.Duration (Milliseconds(..)) + +import Data.Set (Set) +import Data.Set as Set + +type State = + { listens :: Array Listen + , lastCheck :: Maybe String + , error :: Maybe String + , loading :: Boolean + , currentTime :: Maybe Milliseconds + , failedCovers :: Set String + } + +data Action + = Initialize + | Refresh + | ReceiveResponse (Either String (Array Listen)) + | ImageError String + +component :: forall query input output m. MonadAff m => H.Component query input output m +component = + H.mkComponent + { initialState + , render + , eval: H.mkEval $ H.defaultEval + { handleAction = handleAction + , initialize = Just Initialize + } + } + where + initialState _ = + { listens: [] + , lastCheck: Nothing + , error: Nothing + , loading: true + , currentTime: Nothing + , failedCovers: Set.empty + } + + render state = + HH.div + [ HP.class_ (H.ClassName "container") ] + [ HH.h1_ [ HH.text "Recent Tracks" ] + , renderContent state + , HH.p + [ HP.id "last-updated" + , HP.class_ (H.ClassName "small") + ] + [ HH.text $ fromMaybe "" state.lastCheck ] + ] + + renderContent state + | state.loading && state.listens == [] = + HH.ul_ [ HH.li [ HP.class_ (H.ClassName "loading") ] [ HH.text "Loading recent tracks..." ] ] + | Just err <- state.error = + HH.ul_ [ HH.li [ HP.class_ (H.ClassName "error") ] [ HH.text err ] ] + | otherwise = + HH.ul [ HP.id "tracks-container" ] + (mapWithIndex (renderListen state.currentTime state.failedCovers) state.listens) + + renderListen currentTime failedCovers _ (Listen { trackMetadata: TrackMetadata track, listenedAt }) = + let + release = fromMaybe "" track.releaseName + artist = fromMaybe "" track.artistName + + -- Priority: + -- 1. CAA (CAA MBID) + -- 2. CAA (Release MBID) + -- 3. Last.fm proxy + -- 4. Discogs proxy + + mbid = case track.mbidMapping of + Just (MbidMapping { caaReleaseMbid: Just m }) -> Just m + Just (MbidMapping { releaseMbid: Just m }) -> Just m + _ -> Nothing + + lastfmId = "lastfm-" <> artist <> "-" <> release + discogsId = "discogs-" <> artist <> "-" <> release + + coverInfo = case mbid of + Just m -> + if Set.member m failedCovers then + -- Fallback 1: Last.fm + if Set.member lastfmId failedCovers then + -- Fallback 2: Discogs + if Set.member discogsId failedCovers then Nothing + else Just { id: discogsId, url: "/discogs?artist=" <> (fromMaybe "" $ encodeURIComponent artist) <> "&release=" <> (fromMaybe "" $ encodeURIComponent release) } + else Just { id: lastfmId, url: "/lastfm?artist=" <> (fromMaybe "" $ encodeURIComponent artist) <> "&release=" <> (fromMaybe "" $ encodeURIComponent release) } + else Just { id: m, url: "https://coverartarchive.org/release/" <> m <> "/front-250" } + Nothing -> + -- No MBID, try Last.fm then Discogs + if Set.member lastfmId failedCovers then + if Set.member discogsId failedCovers then Nothing + else Just { id: discogsId, url: "/discogs?artist=" <> (fromMaybe "" $ encodeURIComponent artist) <> "&release=" <> (fromMaybe "" $ encodeURIComponent release) } + else Just { id: lastfmId, url: "/lastfm?artist=" <> (fromMaybe "" $ encodeURIComponent artist) <> "&release=" <> (fromMaybe "" $ encodeURIComponent release) } + in + HH.li + [ HP.class_ (H.ClassName "success") ] + [ HH.div + [ HP.class_ (H.ClassName "track-info") ] + [ HH.div + [ HP.class_ (H.ClassName "track-name") ] + [ HH.text $ fromMaybe "Unknown Track" track.trackName ] + , HH.div + [ HP.class_ (H.ClassName "track-artist") ] + [ HH.text artist ] + , HH.div + [ HP.class_ (H.ClassName "track-time") ] + [ let + query = fromMaybe "" $ encodeURIComponent (artist <> " " <> release) + in + HH.span_ + [ HH.a + [ HP.href $ "https://www.discogs.com/search/?q=" <> query <> "&type=release" + , HP.target "_blank" + , HP.class_ (H.ClassName "album-link") + ] + [ HH.text release ] + , HH.text $ " • " <> (fromMaybe "unknown time" $ formatTimeAgo currentTime listenedAt) + ] + ] + ] + , case coverInfo of + Just { id, url } -> + HH.img + [ HP.class_ (H.ClassName "track-cover") + , HP.src url + , HP.alt release + , HE.onError \_ -> ImageError id + ] + Nothing -> HH.text "" + ] + + handleAction = case _ of + Initialize -> do + void $ H.fork $ forever (H.liftAff (delay (Milliseconds 30000.0)) *> handleAction Refresh) + handleAction Refresh + Refresh -> do + H.modify_ _ { loading = true, error = Nothing } + response <- H.liftAff fetchListens + handleAction (ReceiveResponse response) + ReceiveResponse result -> do + nowInstant <- liftEffect now + let nowMs = unInstant nowInstant + let nowStr = "Last check: " <> show nowInstant + case result of + Left err -> H.modify_ _ { loading = false, error = Just err, lastCheck = Just nowStr, currentTime = Just nowMs } + Right listens -> H.modify_ _ { loading = false, listens = listens, lastCheck = Just nowStr, currentTime = Just nowMs } + ImageError mbid -> do + liftEffect $ Console.log $ "Image load failed for: " <> mbid + H.modify_ \state -> state { failedCovers = Set.insert mbid state.failedCovers } + + fetchListens :: Aff (Either String (Array Listen)) + fetchListens = do + res <- AX.get ResponseFormat.json "/proxy" + case res of + Left err -> pure $ Left $ "Network error: " <> AX.printError err + Right response -> + case decodeJson response.body of + Left err -> pure $ Left $ "JSON decode error: " <> show err + Right (ListenBrainzResponse { payload: Payload { listens } }) -> pure $ Right listens + + formatTimeAgo :: Maybe Milliseconds -> Maybe Int -> Maybe String + formatTimeAgo Nothing _ = Nothing + formatTimeAgo _ Nothing = Nothing + formatTimeAgo (Just (Milliseconds nowMs)) (Just timestamp) = + let + nowSecs = floor (nowMs / 1000.0) + diff = nowSecs - timestamp + in Just $ + if diff < 60 then "just now" + else if diff < 3600 then + let mins = diff / 60 in show mins <> " minute" <> (if mins > 1 then "s" else "") <> " ago" + else if diff < 86400 then + let hours = diff / 3600 in show hours <> " hour" <> (if hours > 1 then "s" else "") <> " ago" + else + let days = diff / 86400 in show days <> " day" <> (if days > 1 then "s" else "") <> " ago" + +main :: Effect Unit +main = HA.runHalogenAff do + maybeApp <- HA.selectElement (QuerySelector "#app") + case maybeApp of + Nothing -> HA.awaitBody >>= runUI component unit + Just app -> runUI component unit app diff --git a/src/Main.purs b/src/Main.purs @@ -15,7 +15,7 @@ import Node.HTTP.IncomingMessage as IM import Node.HTTP.Types (ServerResponse, IncomingMessage, IMServer) import Node.HTTP.ServerResponse (setStatusCode, toOutgoingMessage) import Node.HTTP.OutgoingMessage (setHeader, toWriteable) -import Node.Stream (end, writeString) +import Node.Stream (end, writeString, pipe) import Node.Stream.Aff (readableToStringUtf8) import Node.Encoding (Encoding(UTF8)) import Node.Net.Server (listenTcp, listeningH) @@ -23,16 +23,23 @@ import Data.Either (Either(..)) import Effect.Exception as Exception import Effect.Aff (Aff, launchAff_, makeAff, nonCanceler, try) import Unsafe.Coerce (unsafeCoerce) +import Node.FS.Aff as FSA +import Node.Process (getEnv) +import Fetch (fetch, Method(GET)) +import Fetch.Argonaut.Json (fromJson) +import Data.Maybe (Maybe(..), fromMaybe) +import JSURI (encodeURIComponent) +import Node.URL (URL, new', pathname) +import Affjax.RequestHeader (RequestHeader(..)) +import Foreign.Object as Object +import Data.Argonaut.Core (toObject, toArray, toString) +import Data.Array ((!!)) +import Data.Nullable (Nullable, toMaybe) -- Types type Request = IncomingMessage IMServer type Response = ServerResponse -type ProxyConfig = - { port :: Int - , listenBrainzUrl :: String - } - listenBrainzUrl :: String listenBrainzUrl = "https://api.listenbrainz.org/1/user/mtmn/listens" @@ -77,16 +84,23 @@ indexHtml = <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>scrobbler.mtmn.name</title> - <script src="https://unpkg.com/htmx.org@2.0.7"></script> + <link rel="preconnect" href="https://fonts.googleapis.com"> + <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> + <link href="https://fonts.googleapis.com/css2?family=Intel+One+Mono:wght@300;400;500;700&display=swap" rel="stylesheet"> <style> body { - font-family: 'Courier New', 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; - background: #332d38; + font-family: 'Intel One Mono', 'Courier New', 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; + background: #000000; color: #ffffff; margin: 0; padding: 20px; line-height: 1.6; } + + ::selection { + background: #50447f; + color: #ffffff; + } .container { max-width: 800px; @@ -97,6 +111,9 @@ indexHtml = color: #ffffff; margin-bottom: 20px; font-size: 24px; + border-bottom: 2px solid #50447f; + display: inline-block; + padding-bottom: 5px; } ul { @@ -106,19 +123,20 @@ indexHtml = } li { - background: rgba(0, 0, 0, 0.3); - border: 1px solid rgba(80, 68, 127, 0.5); + background: #521e40; + border: 1px solid #50447f; border-radius: 4px; padding: 15px; margin-bottom: 10px; display: flex; justify-content: space-between; align-items: center; + box-shadow: 4px 4px 0px #50447f; } li.success { - background: rgba(185, 208, 170, 0.2); - border-color: rgba(185, 208, 170, 0.3); + background: #521e40; + border-color: #50447f; } .track-info { @@ -134,7 +152,7 @@ indexHtml = .track-artist { font-size: 14px; color: #a0c0d0; - margin-top: 4px; + margin-top: 1px; } .track-time { @@ -142,11 +160,29 @@ indexHtml = color: #9fbfe7; margin-top: 2px; } + + .album-link { + color: #9fbfe7; + text-decoration: underline; + } + + .album-link:hover { + color: #ffffff; + } .status { color: #b9d0aa; font-weight: bold; } + + .track-cover { + width: 60px; + height: 60px; + margin-left: 15px; + border-radius: 4px; + object-fit: cover; + background: rgba(255, 255, 255, 0.05); + } .loading { padding: 20px; @@ -207,125 +243,25 @@ indexHtml = </style> </head> <body> - <div class="container"> - <h1>History</h1> - <ul id="tracks-container" - hx-get="/proxy" - hx-trigger="load, every 30s" - hx-target="#tracks-container" - hx-ext="listenbrainz"> - <li class="loading">Loading recent tracks...</li> - </ul> - - <p class="small"> - <a href="https://listenbrainz.org/user/mtmn/">ListenBrainz</a> - </p> - - <p class="small" id="last-updated"></p> - </div> - - <script> - // Update last updated time - function updateLastUpdated() { - const now = new Date(); - document.getElementById('last-updated').textContent = `${now.toISOString()}`; - } - - // Handle ListenBrainz API response - htmx.defineExtension('listenbrainz', { - onEvent: function(name, evt) { - if (name === 'htmx:afterRequest') { - const xhr = evt.detail.xhr; - if (xhr.status === 200) { - try { - const data = JSON.parse(xhr.responseText); - if (data.payload && data.payload.listens) { - const html = generateTracksHTML(data.payload.listens); - document.getElementById('tracks-container').innerHTML = html; - } else { - document.getElementById('tracks-container').innerHTML = '<li class="error">No tracks found</li>'; - } - } catch (e) { - document.getElementById('tracks-container').innerHTML = '<li class="error">Error parsing response</li>'; - } - } else { - document.getElementById('tracks-container').innerHTML = '<li class="error">Error loading tracks</li>'; - } - updateLastUpdated(); - } - } - }); - - function generateTracksHTML(listens) { - let html = ''; - listens.forEach((listen, index) => { - const track = listen.track_metadata; - const listenedAt = formatTimeAgo(listen.listened_at); - - const trackName = track.track_name || 'Unknown Track'; - const artistName = track.artist_name || 'Unknown Artist'; - const releaseName = track.release_name || 'Unknown Album'; - - const playingIndicator = index === 0 ? '<span class="playing-indicator"></span>' : ''; - - html += ` - <li class="success"> - <div class="track-info"> - <div class="track-name">${playingIndicator}${escapeHtml(trackName)}</div> - <div class="track-artist">${escapeHtml(artistName)}</div> - <div class="track-time">${escapeHtml(releaseName)} • ${listenedAt}</div> - </div> - <span class="status">Played</span> - </li> - `; - }); - return html; - } - - function formatTimeAgo(timestamp) { - const now = Math.floor(Date.now() / 1000); - const diff = now - timestamp; - - if (diff < 60) { - return 'just now'; - } else if (diff < 3600) { - const minutes = Math.floor(diff / 60); - return `${minutes} minute${minutes > 1 ? 's' : ''} ago`; - } else if (diff < 86400) { - const hours = Math.floor(diff / 3600); - return `${hours} hour${hours > 1 ? 's' : ''} ago`; - } else { - const days = Math.floor(diff / 86400); - return `${days} day${days > 1 ? 's' : ''} ago`; - } - } - - function escapeHtml(text) { - const map = { - '&': '&amp;', - '<': '&lt;', - '>': '&gt;', - '"': '&quot;', - "'": '&#39;' - }; - return text.replace(/[&<>"']/g, m => map[m]); - } - - // Initial update - updateLastUpdated(); - </script> + <div id="app"></div> + <script src="/client.js"></script> </body> </html>""" -- Request handler handleRequest :: Request -> Response -> Effect Unit handleRequest req res = do - let path = IM.url req + let rawUrl = IM.url req + url <- new' rawUrl "http://localhost" + path <- pathname url Console.log $ "Request received: " <> path case path of "/" -> serveIndex res "/proxy" -> serveProxy res + "/discogs-cover" -> serveDiscogsCover url res + "/lastfm-cover" -> serveLastfmCover url res + "/client.js" -> serveClientJs res "/favicon.ico" -> serveFavicon res _ -> serveNotFound res @@ -333,13 +269,128 @@ serveIndex :: Response -> Effect Unit serveIndex res = do setHeader "Content-Type" "text/html" (toOutgoingMessage res) setHeader "Access-Control-Allow-Origin" "*" (toOutgoingMessage res) - setHeader "Access-Control-Allow-Methods" "GET, POST, OPTIONS" (toOutgoingMessage res) - setHeader "Access-Control-Allow-Headers" "*" (toOutgoingMessage res) setStatusCode 200 res let w = toWriteable (toOutgoingMessage res) void $ writeString w UTF8 indexHtml end w +serveClientJs :: Response -> Effect Unit +serveClientJs res = do + setHeader "Content-Type" "application/javascript" (toOutgoingMessage res) + setHeader "Access-Control-Allow-Origin" "*" (toOutgoingMessage res) + launchAff_ do + result <- try $ FSA.readTextFile UTF8 "client.js" + liftEffect $ case result of + Right content -> do + setStatusCode 200 res + let w = toWriteable (toOutgoingMessage res) + void $ writeString w UTF8 content + end w + Left _ -> serveNotFound res + +foreign import getQueryParam :: String -> URL -> Effect (Nullable String) + +serveLastfmCover :: URL -> Response -> Effect Unit +serveLastfmCover url res = do + launchAff_ do + artistMaybe <- liftEffect $ getQueryParam "artist" url + releaseMaybe <- liftEffect $ getQueryParam "release" url + let artistStr = fromMaybe "" (toMaybe artistMaybe) + let releaseStr = fromMaybe "" (toMaybe releaseMaybe) + + env <- liftEffect getEnv + let apiKey = Object.lookup "LASTFM_API_KEY" env + case apiKey of + Nothing -> liftEffect $ serveNotFound res + Just k -> do + let searchUrl = "https://ws.audioscrobbler.com/2.0/?method=album.getinfo&api_key=" <> k <> "&artist=" <> (fromMaybe "" $ encodeURIComponent artistStr) <> "&album=" <> (fromMaybe "" $ encodeURIComponent releaseStr) <> "&format=json" + liftEffect $ Console.log $ "Last.fm search: " <> artistStr <> " - " <> releaseStr + + result <- try $ fetch searchUrl { method: GET } + case result of + Right fetchRes -> do + json <- fromJson fetchRes.json + let coverUrl = do + obj <- toObject json + album <- Object.lookup "album" obj >>= toObject + images <- Object.lookup "image" album >>= toArray + -- Try to get the large image (index 2 or 3) + imageObj <- images !! 2 >>= toObject + u <- Object.lookup "#text" imageObj >>= toString + if u == "" then Nothing else Just u + + case coverUrl of + Just urlStr -> do + liftEffect $ Console.log $ "Found Last.fm cover: " <> urlStr + proxyImage urlStr res + Nothing -> do + liftEffect $ Console.log $ "No Last.fm cover found for: " <> artistStr <> " - " <> releaseStr + liftEffect $ serveNotFound res + Left err -> do + liftEffect $ Console.log $ "Last.fm API error: " <> Exception.message err + liftEffect $ serveNotFound res + +serveDiscogsCover :: URL -> Response -> Effect Unit +serveDiscogsCover url res = do + launchAff_ do + artistMaybe <- liftEffect $ getQueryParam "artist" url + releaseMaybe <- liftEffect $ getQueryParam "release" url + + let artistStr = fromMaybe "" (toMaybe artistMaybe) + let releaseStr = fromMaybe "" (toMaybe releaseMaybe) + + env <- liftEffect getEnv + let token = Object.lookup "DISCOGS_TOKEN" env + case token of + Nothing -> do + liftEffect $ Console.log "DISCOGS_TOKEN not found in env" + liftEffect $ serveNotFound res + Just t -> do + let queryStr = artistStr <> " " <> releaseStr + let searchUrl = "https://api.discogs.com/database/search?q=" <> (fromMaybe "" $ encodeURIComponent queryStr) <> "&type=release&per_page=1&token=" <> t + liftEffect $ Console.log $ "Discogs search (broad): " <> queryStr + + result <- try $ fetch searchUrl { method: GET, headers: { "User-Agent": "ScrobblerPureScript/1.0" } } + case result of + Right fetchRes -> do + json <- fromJson fetchRes.json + let coverUrl = do + obj <- toObject json + results <- Object.lookup "results" obj >>= toArray + firstResult <- results !! 0 >>= toObject + cover <- Object.lookup "cover_image" firstResult >>= toString + pure cover + + case coverUrl of + Just urlStr -> do + liftEffect $ Console.log $ "Found Discogs cover: " <> urlStr + proxyImage urlStr res + Nothing -> do + liftEffect $ Console.log $ "No Discogs cover found for: " <> queryStr + liftEffect $ serveNotFound res + Left err -> do + liftEffect $ Console.log $ "Discogs API error: " <> Exception.message err + liftEffect $ serveNotFound res + +proxyImage :: String -> Response -> Aff Unit +proxyImage urlStr res = liftEffect $ do + imgReq <- HTTPS.get urlStr + imgReq # on_ Client.responseH \imgRes -> do + let imgSc = IM.statusCode imgRes + setStatusCode imgSc res + setHeader "Content-Type" (fromMaybe "image/jpeg" $ Object.lookup "content-type" (IM.headers imgRes)) (toOutgoingMessage res) + setHeader "Cache-Control" "public, max-age=86400" (toOutgoingMessage res) + + let reader = IM.toReadable imgRes + let writer = toWriteable (toOutgoingMessage res) + void $ pipe reader writer + + let errorH = EventHandle "error" mkEffectFn1 + on_ errorH (\err -> do + Console.log $ "Image proxy error: " <> Exception.message err + serveNotFound res + ) (unsafeCoerce imgReq) + serveProxy :: Response -> Effect Unit serveProxy res = do setHeader "Content-Type" "application/json" (toOutgoingMessage res) @@ -383,6 +434,9 @@ startServer port = do listenTcp netServer { host: "127.0.0.1", port, backlog: 128 } +foreign import dotenvConfig :: Effect Unit + main :: Effect Unit main = do + dotenvConfig startServer 8000 diff --git a/src/Types.purs b/src/Types.purs @@ -0,0 +1,82 @@ +module Types where + +import Prelude + +import Data.Argonaut (class DecodeJson, decodeJson, (.:), (.:?)) +import Data.Maybe (Maybe) +import Data.Generic.Rep (class Generic) + +newtype ListenBrainzResponse = ListenBrainzResponse + { payload :: Payload + } + +derive instance eqListenBrainzResponse :: Eq ListenBrainzResponse +derive instance genericListenBrainzResponse :: Generic ListenBrainzResponse _ + +instance DecodeJson ListenBrainzResponse where + decodeJson json = do + obj <- decodeJson json + payload <- obj .: "payload" + pure $ ListenBrainzResponse { payload } + +newtype Payload = Payload + { listens :: Array Listen + } + +derive instance eqPayload :: Eq Payload +derive instance genericPayload :: Generic Payload _ + +instance DecodeJson Payload where + decodeJson json = do + obj <- decodeJson json + listens <- obj .: "listens" + pure $ Payload { listens } + +newtype Listen = Listen + { trackMetadata :: TrackMetadata + , listenedAt :: Maybe Int + } + +derive instance eqListen :: Eq Listen +derive instance genericListen :: Generic Listen _ + +instance DecodeJson Listen where + decodeJson json = do + obj <- decodeJson json + trackMetadata <- obj .: "track_metadata" + listenedAt <- obj .:? "listened_at" + pure $ Listen { trackMetadata, listenedAt } + +newtype TrackMetadata = TrackMetadata + { trackName :: Maybe String + , artistName :: Maybe String + , releaseName :: Maybe String + , mbidMapping :: Maybe MbidMapping + } + +derive instance eqTrackMetadata :: Eq TrackMetadata +derive instance genericTrackMetadata :: Generic TrackMetadata _ + +instance DecodeJson TrackMetadata where + decodeJson json = do + obj <- decodeJson json + trackName <- obj .:? "track_name" + artistName <- obj .:? "artist_name" + releaseName <- obj .:? "release_name" + mbidMapping <- obj .:? "mbid_mapping" + pure $ TrackMetadata { trackName, artistName, releaseName, mbidMapping } + +newtype MbidMapping = MbidMapping + { releaseMbid :: Maybe String + , caaReleaseMbid :: Maybe String + } + +derive instance eqMbidMapping :: Eq MbidMapping +derive instance genericMbidMapping :: Generic MbidMapping _ + +instance DecodeJson MbidMapping where + decodeJson json = do + obj <- decodeJson json + releaseMbid <- obj .:? "release_mbid" + caaReleaseMbid <- obj .:? "caa_release_mbid" + pure $ MbidMapping { releaseMbid, caaReleaseMbid } diff --git a/test/Main.purs b/test/Main.purs @@ -0,0 +1,11 @@ +module Test.Main where + +import Prelude + +import Effect (Effect) +import Effect.Class.Console (log) + +main :: Effect Unit +main = do + log "🍝" + log "You should add some tests."