diff --git a/README.md b/README.md index 65927023..8e64a840 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,7 @@ Widgets and the services they read: - **Disk health**: TrueNAS, Unraid, Scrutiny - **Backup**: Duplicati, Kopia, Proxmox Backup Server, Backrest - **Downloads**: qBittorrent, Transmission, Deluge, SABnzbd, NZBGet +- **Up Next**: the next episodes, films and albums from Sonarr, Radarr and Lidarr - **Connections**: Gluetun, Psiphon Conduit, Netbird, Plausible, Umami - **Dashboard switch**: links to your other Stackyard dashboards diff --git a/api/src/widgets.js b/api/src/widgets.js index 5113c847..fa3075c7 100644 --- a/api/src/widgets.js +++ b/api/src/widgets.js @@ -26,6 +26,7 @@ const VALID_GLYPHS = new Set([ 'merge', 'panels', 'parcel', + 'calendar', ]); const VALID_FIELDTYPES = new Set([ 'text', diff --git a/api/test/config-secrets.test.js b/api/test/config-secrets.test.js index bd501b6a..7e4828b4 100644 --- a/api/test/config-secrets.test.js +++ b/api/test/config-secrets.test.js @@ -417,3 +417,64 @@ test('a login password is not refilled for another username', () => { preserveAllSecrets(incoming, stored); assert.equal(incoming.items[0].monitoring.activity.login.password, ''); }); + +function upNextWith(sources, extra = {}) { + return { items: [{ id: 'u1', type: 'widget', widgetType: 'upnext', widgetConfig: { sources, ...extra } }] }; +} +const SONARR = { app: 'sonarr', url: 'http://sonarr:8989', apiKey: 'SON', skipTls: false }; +const RADARR = { + app: 'radarr', + url: 'http://radarr:7878', + apiKey: 'RAD', + skipTls: false, + releases: ['cinema', 'digital'], +}; +const sentAs = (row, savedRow) => { + const { apiKey, ...rest } = row; + return { ...rest, apiKeySet: true, savedRow }; +}; + +test('Up Next: switching unmonitored, release types or a Link URL keeps every app key', () => { + const next = upNextWith( + [sentAs({ ...SONARR, href: 'https://tv.example' }, 0), sentAs({ ...RADARR, releases: ['physical'] }, 1)], + { unmonitored: true }, + ); + const { withheld } = preserveAllSecrets(next, upNextWith([SONARR, RADARR])); + assert.deepEqual(withheld, []); + assert.deepEqual( + next.items[0].widgetConfig.sources.map(r => r.apiKey), + ['SON', 'RAD'], + ); +}); + +test('Up Next: removing the first app keeps the second app its own key', () => { + const next = upNextWith([sentAs(RADARR, 1)]); + const { withheld } = preserveAllSecrets(next, upNextWith([SONARR, RADARR])); + assert.deepEqual(withheld, []); + assert.equal(next.items[0].widgetConfig.sources[0].apiKey, 'RAD'); +}); + +test('Up Next: an app whose address or app type changed loses only its own key', () => { + for (const change of [{ url: 'http://elsewhere:8989' }, { app: 'lidarr' }, { skipTls: true }]) { + const next = upNextWith([sentAs({ ...SONARR, ...change }, 0), sentAs(RADARR, 1)]); + const { withheld } = preserveAllSecrets(next, upNextWith([SONARR, RADARR])); + assert.deepEqual(withheld, [{ id: 'u1', label: 'u1' }], JSON.stringify(change)); + assert.deepEqual( + next.items[0].widgetConfig.sources.map(r => r.apiKey), + [undefined, 'RAD'], + JSON.stringify(change), + ); + } +}); + +test('Up Next: keys never reach the browser', () => { + const sent = scrubAllSecrets(structuredClone(upNextWith([SONARR, RADARR]))); + const rows = sent.items[0].widgetConfig.sources; + assert.deepEqual( + rows.map(r => [r.apiKey, r.apiKeySet]), + [ + [undefined, true], + [undefined, true], + ], + ); +}); diff --git a/api/test/upnext-data.test.js b/api/test/upnext-data.test.js new file mode 100644 index 00000000..20849979 --- /dev/null +++ b/api/test/upnext-data.test.js @@ -0,0 +1,447 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); + +const widget = path.join(__dirname, '..', '..', 'ui', 'widgets', 'upnext'); +const items = require(path.join(widget, 'upnext-items.js')); +const data = require(path.join(widget, 'data.js')); +const { errorParts } = require('../test-support/widget-ctx'); +const { normalizeBase } = require('../src/widget-data'); +const { serviceLink } = require('../../ui/js/service-link.js'); + +const HOUR = 3600 * 1000; +const DAY = 24 * HOUR; +const NOW = Date.parse('2026-10-25T18:00:00Z'); + +function ctxFor(config, route) { + const calls = []; + const ctx = { + config, + normalizeBase, + serviceLink, + fetchJSON: async (url, opts = {}) => { + calls.push({ url, opts }); + return route(url, opts); + }, + ...errorParts(), + }; + return { ctx, calls }; +} + +const idOf = title => [...title].reduce((h, c) => (h * 31 + c.charCodeAt(0)) % 100000, 7) + 1; +const ep = (title, at, season, episode, extra = {}) => ({ + seriesId: idOf(title), + series: { title, titleSlug: title.toLowerCase().replace(/ /g, '-'), runtime: 50 }, + airDateUtc: at, + seasonNumber: season, + episodeNumber: episode, + title: 'Name ' + episode, + ...extra, +}); + +const sonarrServe = episodes => url => { + const one = /\/api\/v3\/series\/(\d+)$/.exec(url); + if (one) { + const e = episodes.find(x => x.seriesId === Number(one[1])); + return e ? { status: 200, data: e.series } : { status: 404, data: {} }; + } + if (url.includes('/api/v3/calendar')) return { status: 200, data: episodes.map(({ series, ...e }) => e) }; + throw new Error('unexpected ' + url); +}; + +test('Sonarr: an episode keeps its time, code and name; TBA names are dropped', () => { + const [a, b] = items.sonarrItems([ + ep('Northbound', '2026-10-25T21:00:00Z', 2, 4), + ep('Fieldwork', '2026-10-26T20:00:00Z', 1, 8, { title: 'TBA' }), + ]); + assert.equal(a.at, '2026-10-25T21:00:00.000Z'); + assert.deepEqual([a.season, a.episode, a.name, a.path], [2, 4, 'Name 4', '/series/northbound']); + assert.equal(a.until, Date.parse('2026-10-25T21:50:00Z'), 'ends after the series runtime'); + assert.equal(b.name, ''); +}); + +test('Sonarr: a season dropped at once is one row with an episode range', () => { + const t = '2026-10-30T07:00:00Z'; + const out = items.sonarrItems( + [1, 2, 3, 4].map(n => ep('Small Hours', t, 3, n, n === 4 ? { finaleType: 'season' } : {})), + ); + assert.equal(out.length, 1); + assert.deepEqual( + [out[0].episode, out[0].lastEpisode, out[0].name, out[0].finale, out[0].premiere], + [1, 4, '', 'season', true], + ); +}); + +test('Sonarr: a daily show drops its date-based code and name, and keeps any other name', () => { + const [a, b] = items.sonarrItems([ + ep('Quiz Hour', '2026-10-26T23:00:00Z', 2026, 178, { + series: { title: 'Quiz Hour', seriesType: 'daily' }, + title: 'Mon, Oct 26, 2026', + }), + ep('Quiz Hour', '2026-10-27T23:00:00Z', 2026, 179, { series: { title: 'Quiz Hour' } }), + ]); + assert.deepEqual([a.season, a.episode, a.name, a.premiere], [null, null, '', false]); + assert.notEqual(a.at, b.at, 'two daily episodes stay two rows'); + assert.deepEqual([b.season, b.episode], [null, null], 'a season numbered by year is date-based too'); + assert.equal(b.name, 'Name 179', 'a guest list or topic is not a date'); + assert.equal( + items.sonarrItems([ep('Talk', '2026-10-27T23:00:00Z', 3, 9, { title: 'October 27, 2026' })])[0].name, + '', + ); +}); + +test('Sonarr: specials are not premieres, and rows with no series or time are skipped', () => { + const out = items.sonarrItems([ + ep('Northbound', '2026-10-25T21:00:00Z', 0, 1), + { airDateUtc: '2026-10-25T21:00:00Z', seasonNumber: 1, episodeNumber: 1 }, + ep('Fieldwork', 'not a date', 1, 1), + null, + 'x', + ]); + assert.equal(out.length, 1); + assert.equal(out[0].premiere, false); +}); + +test('Radarr: each selected release date in the window is a day row, read as a calendar day', () => { + const m = { + title: 'Glass Harbor', + titleSlug: 'glass-harbor-2026', + year: 2026, + inCinemas: '2026-09-01T00:00:00Z', + digitalRelease: '2026-10-27T00:00:00Z', + physicalRelease: '2026-11-20T00:00:00Z', + }; + const from = NOW - DAY; + const to = NOW + 14 * DAY; + const all = items.radarrItems([m], from, to); + assert.deepEqual( + all.map(i => [i.release, i.day, i.at]), + [['digital', '2026-10-27', null]], + ); + assert.equal(all[0].path, '/movie/glass-harbor-2026'); + assert.deepEqual( + items.radarrItems([m], from, NOW + 60 * DAY, ['physical']).map(i => i.release), + ['physical'], + ); + assert.deepEqual(items.radarrItems([m], from, to, []), [], 'no release types selected shows no films'); +}); + +test('Lidarr: albums carry artist and type, and link by album id', () => { + const [a] = items.lidarrItems([ + { + title: 'Low Tide', + releaseDate: '2026-10-30T00:00:00Z', + albumType: 'Album', + foreignAlbumId: 'abc', + artist: { artistName: 'Marrow Lane' }, + }, + ]); + assert.deepEqual( + [a.kind, a.day, a.artist, a.albumType, a.path], + ['album', '2026-10-30', 'Marrow Lane', 'album', '/album/abc'], + ); + assert.equal(items.lidarrItems([{ title: 'x', releaseDate: '', albumType: 'Broadcast' }]).length, 0); +}); + +test('merge: drops what has ended, removes a second instance’s copy, sorts and caps', () => { + const s1 = items.sonarrItems([ + ep('Northbound', '2026-10-25T21:00:00Z', 2, 4), + ep('Old', '2026-10-25T10:00:00Z', 1, 1), + ]); + const s2 = items.sonarrItems([ep('Northbound', '2026-10-25T21:00:00Z', 2, 4)]); + const films = items.lidarrItems([ + { title: 'Low Tide', releaseDate: '2026-10-25T00:00:00Z', artist: { artistName: 'A' } }, + ]); + const out = items.mergeItems([s1, s2, films], NOW); + assert.deepEqual( + out.map(i => i.title), + ['Low Tide', 'Northbound'], + ); + assert.ok(!('key' in out[0]) && !('until' in out[0])); + assert.equal(out[1].ends, '2026-10-25T21:50:00.000Z'); + const many = items.sonarrItems( + Array.from({ length: 30 }, (_, n) => ep('S' + n, new Date(NOW + (n + 1) * HOUR).toISOString(), 1, 2)), + ); + assert.equal(items.mergeItems([many], NOW).length, items.MAX_ITEMS); +}); + +test('data: reads every app with its key, merges, and links each row to its page', async t => { + t.mock.timers.enable({ apis: ['Date'], now: NOW }); + const many = Array.from({ length: 9 }, (_, n) => ep('Show ' + n, new Date(NOW + (n + 1) * DAY).toISOString(), 1, 2)); + const { ctx, calls } = ctxFor( + { + sources: [ + { app: 'sonarr', url: 'sonarr.lan:8989', apiKey: 'k1' }, + { app: 'radarr', url: 'http://radarr.lan:7878/', apiKey: 'k2', href: 'https://films.example' }, + ], + }, + url => { + if (url.includes(':8989/')) return sonarrServe(many)(url); + if (url.includes(':7878/api/v3/calendar')) + return { + status: 200, + data: [{ title: 'Glass Harbor', titleSlug: 'glass-harbor', digitalRelease: '2026-10-27T00:00:00Z' }], + }; + throw new Error('unexpected ' + url); + }, + ); + const out = await data(ctx); + const calendars = calls.filter(c => c.url.includes('/calendar')); + assert.equal(calendars.length, 2, 'enough rows in 14 days: no second, longer read'); + assert.equal(calendars[0].opts.headers['X-Api-Key'], 'k1'); + assert.doesNotMatch(calendars[0].url, /includeSeries|unmonitored/); + assert.equal( + calls.filter(c => c.url.includes('/api/v3/series/')).length, + 9, + 'one lookup per series that can reach the list', + ); + assert.equal(out.days, 14); + assert.deepEqual(out.missing, []); + const film = out.items.find(i => i.kind === 'film'); + assert.equal(film.link, 'https://films.example/movie/glass-harbor'); + assert.ok(!('path' in film)); + assert.equal(out.items[0].link, 'http://sonarr.lan:8989/series/show-0'); +}); + +test('data: a quiet fortnight reads 90 days ahead', async t => { + t.mock.timers.enable({ apis: ['Date'], now: NOW }); + const { ctx, calls } = ctxFor( + { sources: [{ app: 'lidarr', url: 'lidarr.lan', apiKey: 'k' }], unmonitored: true }, + () => ({ status: 200, data: [] }), + ); + const out = await data(ctx); + assert.equal(calls.length, 2); + assert.match(calls[0].url, /\/api\/v1\/calendar\?.*unmonitored=true.*includeArtist=true/); + const end = new URL(calls[1].url).searchParams.get('end'); + assert.equal(Date.parse(end), NOW + 90 * DAY); + assert.equal(out.days, 90); + assert.deepEqual(out.items, []); +}); + +test('data: one app failing leaves the rest and names it; all failing reports the first', async t => { + t.mock.timers.enable({ apis: ['Date'], now: NOW }); + const sources = [ + { app: 'sonarr', url: 'sonarr.lan', apiKey: 'k' }, + { app: 'lidarr', url: 'lidarr.lan', apiKey: 'bad' }, + ]; + const { ctx } = ctxFor({ sources }, url => { + if (url.includes('lidarr')) return { status: 401, data: {} }; + return sonarrServe([ep('Northbound', '2026-10-25T21:00:00Z', 2, 4)])(url); + }); + const out = await data(ctx); + assert.deepEqual(out.missing, [{ app: 'lidarr', kind: 'auth' }]); + assert.equal(out.items.length, 1); + + const down = ctxFor({ sources }, () => { + throw Object.assign(new Error('refused'), { code: 'ECONNREFUSED' }); + }); + await assert.rejects(data(down.ctx), /refused/); +}); + +test('data: a missing app, URL or key, or a key that cannot be a header, is not set up', async () => { + for (const config of [ + {}, + { sources: [{ app: 'plex', url: 'x', apiKey: 'k' }] }, + { sources: [{ app: 'sonarr', url: '', apiKey: 'k' }] }, + { sources: [{ app: 'sonarr', url: 'x', apiKey: '' }] }, + { sources: [{ app: 'sonarr', url: 'x', apiKey: 'a\r\nb' }] }, + ]) { + const { ctx, calls } = ctxFor(config, () => ({ status: 200, data: [] })); + await assert.rejects(data(ctx), e => e.kind === 'invalid', JSON.stringify(config)); + assert.equal(calls.length, 0); + } +}); + +test('data: an HTML or object reply is not a calendar', async () => { + const { ctx } = ctxFor({ sources: [{ app: 'radarr', url: 'r', apiKey: 'k' }] }, () => ({ + status: 200, + data: '', + })); + await assert.rejects(data(ctx), /not a calendar/); +}); + +test('Sonarr: only the series that can reach the list are looked up, soonest first', () => { + const eps = [ + ep('Ended', '2026-10-25T10:00:00Z', 1, 1), + ...Array.from({ length: 20 }, (_, n) => ep('S' + n, new Date(NOW + (n + 1) * HOUR).toISOString(), 1, 1)), + { ...ep('S0', new Date(NOW + 30 * HOUR).toISOString(), 1, 2) }, + { seriesId: 'x', airDateUtc: new Date(NOW + HOUR).toISOString() }, + ]; + const ids = items.sonarrSeriesIds(eps, NOW, 12); + assert.equal(ids.length, 12); + assert.equal(ids[0], idOf('S0')); + assert.ok(!ids.includes(idOf('Ended')), 'an episode that has ended needs no series'); +}); + +test('data: series lookups are cached per server, and a series that is gone is skipped', async t => { + t.mock.timers.enable({ apis: ['Date'], now: NOW }); + const eps = [ep('Kept', '2026-10-25T21:00:00Z', 1, 2), ep('Gone', '2026-10-25T22:00:00Z', 1, 3)]; + const serve = sonarrServe(eps); + const route = url => (url.endsWith('/series/' + idOf('Gone')) ? { status: 404, data: {} } : serve(url)); + const first = ctxFor({ sources: [{ app: 'sonarr', url: 'cache.lan', apiKey: 'k' }] }, route); + const out = await data(first.ctx); + assert.deepEqual( + out.items.map(i => i.title), + ['Kept'], + ); + const again = ctxFor({ sources: [{ app: 'sonarr', url: 'cache.lan', apiKey: 'k' }] }, route); + await data(again.ctx); + assert.equal(again.calls.filter(c => c.url.endsWith('/series/' + idOf('Kept'))).length, 0, 'served from the cache'); +}); + +test('data: the card links to the first app calendar a browser can open', async t => { + t.mock.timers.enable({ apis: ['Date'], now: NOW }); + const { ctx } = ctxFor( + { + sources: [ + { app: 'sonarr', url: 'http://sonarr:8989', apiKey: 'k' }, + { app: 'radarr', url: 'http://radarr.lan:7878', apiKey: 'k' }, + ], + }, + url => (url.includes('8989') ? sonarrServe([])(url) : { status: 200, data: [] }), + ); + const out = await data(ctx); + assert.equal(out.calendar, 'http://radarr.lan:7878/calendar', 'a bare container name is not a browser address'); +}); + +test('worst case: a large calendar is cut to the list without a series lookup per episode', async t => { + t.mock.timers.enable({ apis: ['Date'], now: NOW }); + const eps = Array.from({ length: 20000 }, (_, n) => + ep('Show ' + (n % 400), new Date(NOW + (n + 1) * 60000).toISOString(), 1 + (n % 9), 1 + (n % 30)), + ); + const { ctx, calls } = ctxFor({ sources: [{ app: 'sonarr', url: 'big.lan', apiKey: 'k' }] }, sonarrServe(eps)); + const out = await data(ctx); + assert.equal(out.items.length, items.MAX_ITEMS); + assert.ok(calls.filter(c => c.url.includes('/series/')).length <= items.MAX_ITEMS); +}); + +test('worst case: a timeout or refusal names that app, and a failed longer read keeps the shorter one and its days', async t => { + t.mock.timers.enable({ apis: ['Date'], now: NOW }); + let radarrReads = 0; + const { ctx } = ctxFor( + { + sources: [ + { app: 'radarr', url: 'slow.lan', apiKey: 'k' }, + { app: 'lidarr', url: 'down.lan', apiKey: 'k' }, + ], + }, + url => { + if (url.includes('down.lan')) throw Object.assign(new Error('refused'), { code: 'ECONNREFUSED' }); + radarrReads++; + if (radarrReads > 1) throw new Error('Timed out'); + return { status: 200, data: [{ title: 'Glass Harbor', digitalRelease: '2026-10-27T00:00:00Z' }] }; + }, + ); + const out = await data(ctx); + assert.deepEqual(out.missing, [{ app: 'lidarr', kind: 'network' }]); + assert.equal(out.days, 14, 'the empty state must not claim 90 days'); + assert.deepEqual( + out.items.map(i => i.title), + ['Glass Harbor'], + 'the 14-day read stands in for the failed 90-day one', + ); +}); + +test('bad config: more than six apps are cut to six, and unknown apps are ignored', async t => { + t.mock.timers.enable({ apis: ['Date'], now: NOW }); + const sources = [ + { app: 'plex', url: 'x', apiKey: 'k' }, + ...Array.from({ length: 9 }, (_, n) => ({ app: 'radarr', url: `r${n}.lan`, apiKey: 'k' })), + ]; + const { ctx, calls } = ctxFor({ sources }, () => ({ status: 200, data: [] })); + await data(ctx); + const hosts = new Set(calls.map(c => new URL(c.url).hostname)); + assert.equal(hosts.size, 5, 'six rows read, one of them unknown'); +}); + +test('bad data: wrong types in a reply never throw', () => { + const junk = [ + null, + 1, + 'x', + [], + {}, + { airDateUtc: 5 }, + { series: 'x', airDateUtc: '2026-10-25T21:00:00Z' }, + { title: {}, digitalRelease: 7 }, + ]; + assert.deepEqual(items.sonarrItems(junk), []); + assert.deepEqual(items.radarrItems(junk, 0, Infinity), []); + assert.deepEqual(items.lidarrItems(junk), []); + assert.deepEqual(items.sonarrSeriesIds(junk, NOW, 12), []); + assert.deepEqual(items.sonarrItems('nope'), []); +}); + +test('data: a timeout from the proxy or the widget budget is named a timeout', async t => { + t.mock.timers.enable({ apis: ['Date'], now: NOW }); + const { ctx } = ctxFor( + { + sources: [ + { app: 'sonarr', url: 'sonarr.lan', apiKey: 'k' }, + { app: 'radarr', url: 'slow.lan', apiKey: 'k' }, + ], + }, + url => { + if (url.includes('slow.lan')) throw new Error('Timed out'); + return sonarrServe([ep('Northbound', '2026-10-25T21:00:00Z', 2, 4)])(url); + }, + ); + const out = await data(ctx); + assert.deepEqual(out.missing, [{ app: 'radarr', kind: 'timeout' }]); +}); + +test('time zones: a day item outlives UTC midnight until its day ends at UTC-12', () => { + const album = items.lidarrItems([ + { title: 'Low Tide', releaseDate: '2026-10-30T00:00:00Z', artist: { artistName: 'A' } }, + ]); + const film = items.radarrItems( + [{ title: 'Glass Harbor', digitalRelease: '2026-10-30T00:00:00Z' }], + Date.parse('2026-10-29T00:00:00Z'), + Date.parse('2026-11-30T00:00:00Z'), + ['digital'], + ); + const losAngelesEvening = Date.parse('2026-10-31T00:30:00Z'); + assert.equal(items.mergeItems([album, film], losAngelesEvening).length, 2); + const lastZoneMidnight = Date.parse('2026-10-31T12:00:00Z'); + assert.equal(items.mergeItems([album, film], lastZoneMidnight).length, 0); +}); + +test('bad data: a runtime or air date past the Date range skips that row, not the widget', async t => { + t.mock.timers.enable({ apis: ['Date'], now: NOW }); + const odd = [ + ep('Forever', '2026-10-25T20:00:00Z', 1, 2, { runtime: 1e15 }), + ep('Far', '+275760-09-13T00:00:00Z', 1, 2), + ]; + const { ctx } = ctxFor( + { + sources: [ + { app: 'sonarr', url: 'sonarr.lan', apiKey: 'k' }, + { app: 'radarr', url: 'radarr.lan', apiKey: 'k' }, + ], + }, + url => + url.includes('radarr') + ? { status: 200, data: [{ title: 'Glass Harbor', digitalRelease: '2026-10-27T00:00:00Z' }] } + : sonarrServe(odd)(url), + ); + const out = await data(ctx); + assert.deepEqual( + out.items.map(i => i.title), + ['Forever', 'Glass Harbor'], + ); + assert.equal(out.items[0].ends, '2026-10-25T20:50:00.000Z', 'an impossible runtime falls back to the series runtime'); +}); + +test('worst case: the series cache keeps short text only, whatever the reply size', async t => { + t.mock.timers.enable({ apis: ['Date'], now: NOW }); + const huge = 'x'.repeat(100000); + const e = ep('Huge', '2026-10-25T21:00:00Z', 1, 2, { + seriesId: 424242, + series: { title: huge, titleSlug: huge, runtime: 1e15, seriesType: { a: huge } }, + }); + const { ctx } = ctxFor({ sources: [{ app: 'sonarr', url: 'huge.lan', apiKey: 'k' }] }, sonarrServe([e])); + const [row] = (await data(ctx)).items; + assert.equal(row.title.length, 300); + assert.ok(row.link.length < 400); + assert.equal(row.ends, '2026-10-25T22:00:00.000Z'); +}); diff --git a/ui/admin/index.html b/ui/admin/index.html index 7a36a740..2a66528c 100644 --- a/ui/admin/index.html +++ b/ui/admin/index.html @@ -318,6 +318,6 @@

General

- + diff --git a/ui/index.html b/ui/index.html index ddf74278..e4d57689 100644 --- a/ui/index.html +++ b/ui/index.html @@ -56,5 +56,5 @@ - + diff --git a/ui/js/admin-app-form.js b/ui/js/admin-app-form.js index ae25b97b..5a3434ff 100644 --- a/ui/js/admin-app-form.js +++ b/ui/js/admin-app-form.js @@ -33,13 +33,13 @@ import { reveal, setTogDisabled, swapContent, -} from '/js/admin-shared.js?v=a3e8bc29'; -import { createListbox } from '/js/listbox.js?v=9a579bea'; +} from '/js/admin-shared.js?v=8fec4e2f'; +import { createListbox } from '/js/listbox.js?v=3e47b403'; import { MAX_LABELS } from '/js/badge-logic.js?v=85b1bc96'; -import { renderColorControl, BADGE_DEFAULT } from '/js/admin-color-control.js?v=e2a0169e'; +import { renderColorControl, BADGE_DEFAULT } from '/js/admin-color-control.js?v=d444b337'; import { badgeErrorAdvice, TONE } from '/js/admin-error.js?v=a60b17ac'; -import { fluidHoverClear, fluidHoverKb } from '/js/fluid-hover.js?v=cb886e86'; -import { iconSvg } from '/js/icon-set.js?v=941db5a1'; +import { fluidHoverClear, fluidHoverKb } from '/js/fluid-hover.js?v=8b4aadd4'; +import { iconSvg } from '/js/icon-set.js?v=26655aca'; export function buildFolderForm(body, item) { const children = item?.children || []; diff --git a/ui/js/admin-auth.js b/ui/js/admin-auth.js index 45ed2b51..41c5f3d4 100644 --- a/ui/js/admin-auth.js +++ b/ui/js/admin-auth.js @@ -1,4 +1,4 @@ -import { apiGet, apiPost } from '/js/admin-shared.js?v=a3e8bc29'; +import { apiGet, apiPost } from '/js/admin-shared.js?v=8fec4e2f'; import { initI18n, t } from '/js/i18n.js?v=ed7c5565'; import { loginErrorKey } from '/js/admin-error.js?v=a60b17ac'; import { pwStrength } from '/js/password-strength.js?v=389e0ed0'; diff --git a/ui/js/admin-color-control.js b/ui/js/admin-color-control.js index 3345f740..0c6b6920 100644 --- a/ui/js/admin-color-control.js +++ b/ui/js/admin-color-control.js @@ -1,9 +1,9 @@ -import { PE_SVG, initInlineEdit, toast, reveal } from '/js/admin-shared.js?v=a3e8bc29'; +import { PE_SVG, initInlineEdit, toast, reveal } from '/js/admin-shared.js?v=8fec4e2f'; import { t } from '/js/i18n.js?v=ed7c5565'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; import { qa, q } from '/js/utils.js?v=cfee1271'; -import { iconSvg } from '/js/icon-set.js?v=941db5a1'; -import { createListbox } from '/js/listbox.js?v=9a579bea'; +import { iconSvg } from '/js/icon-set.js?v=26655aca'; +import { createListbox } from '/js/listbox.js?v=3e47b403'; import { HUE_NAMES, ROLE_NAMES, TILE_KEYWORDS, pageTheme, tileColor } from '/js/palette.js?v=3fb8ae43'; const CC_SWATCHES = ['#1c1c1e', '#8e8e93', '#f2f2f7', '#ff393c', '#ffcd00', '#35c759', '#0289ff', '#cb30df']; diff --git a/ui/js/admin-list.js b/ui/js/admin-list.js index 8ca7f5ba..fb6e1a42 100644 --- a/ui/js/admin-list.js +++ b/ui/js/admin-list.js @@ -18,13 +18,13 @@ import { listGroup } from '/js/admin-drag-logic.js?v=1d1d553e'; import { initDrag, wireDockZone, wireRowDrag } from '/js/admin-drag.js?v=14bd8c6c'; import { DOCK_MAX } from '/js/limits.js?v=31048a24'; import { formatNumber } from '/js/format-number.js?v=abcd4ae4'; -import { paintIcon, toast } from '/js/admin-shared.js?v=a3e8bc29'; +import { paintIcon, toast } from '/js/admin-shared.js?v=8fec4e2f'; import { clr, el, focusFirst, initial, qa, setUserText } from '/js/utils.js?v=cfee1271'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; import { t } from '/js/i18n.js?v=ed7c5565'; -import { sizeLabel } from '/js/admin-widget-form.js?v=2427cc08'; -import { widgetGlyph } from '/js/widget-glyphs.js?v=f0c89a0a'; -import { iconSvg } from '/js/icon-set.js?v=941db5a1'; +import { sizeLabel } from '/js/admin-widget-form.js?v=b0b45771'; +import { widgetGlyph } from '/js/widget-glyphs.js?v=479a53ee'; +import { iconSvg } from '/js/icon-set.js?v=26655aca'; /** @type {{ openModal: (idx: number|null) => void, openFolderPicker: (appId: string|null, targetFolderId?: string|null) => void, diff --git a/ui/js/admin-settings.js b/ui/js/admin-settings.js index 2e419b23..c08f7fdd 100644 --- a/ui/js/admin-settings.js +++ b/ui/js/admin-settings.js @@ -1,4 +1,4 @@ -import { toast, apiGet, apiPost, errorText, reveal, swapContent, wireInlineEdit } from '/js/admin-shared.js?v=a3e8bc29'; +import { toast, apiGet, apiPost, errorText, reveal, swapContent, wireInlineEdit } from '/js/admin-shared.js?v=8fec4e2f'; import { socketProbeAdvice } from '/js/admin-error.js?v=a60b17ac'; import { pwStrength } from '/js/password-strength.js?v=389e0ed0'; import { t } from '/js/i18n.js?v=ed7c5565'; @@ -14,7 +14,7 @@ import { confirmText, promptModal } from '/js/modal.js?v=6b0320bd'; import { el, inp, setUserText } from '/js/utils.js?v=cfee1271'; import { formatNumber } from '/js/format-number.js?v=abcd4ae4'; import { serialWrites } from '/js/admin-save-logic.js?v=32cb6aeb'; -import { renderColorControl } from '/js/admin-color-control.js?v=e2a0169e'; +import { renderColorControl } from '/js/admin-color-control.js?v=d444b337'; import { BACKDROP } from '/js/background.js?v=72db97ac'; import { ALLOWED_HOSTS_MAX, diff --git a/ui/js/admin-shared.js b/ui/js/admin-shared.js index fafe38bb..148da1d2 100644 --- a/ui/js/admin-shared.js +++ b/ui/js/admin-shared.js @@ -5,7 +5,7 @@ import { t } from '/js/i18n.js?v=ed7c5565'; import { errorAdvice } from '/js/admin-error.js?v=a60b17ac'; import { formatList, formatNumber } from '/js/format-number.js?v=abcd4ae4'; import { iconChain } from '/js/icons.js?v=5ac01d72'; -import { iconSvg } from '/js/icon-set.js?v=941db5a1'; +import { iconSvg } from '/js/icon-set.js?v=26655aca'; import { blockingScreenFor, recoveryShown, showBlockingScreen } from '/js/config-recovery.js?v=85138697'; export const API = ''; diff --git a/ui/js/admin-widget-form.js b/ui/js/admin-widget-form.js index 6c46e455..c6b0cd24 100644 --- a/ui/js/admin-widget-form.js +++ b/ui/js/admin-widget-form.js @@ -1,12 +1,12 @@ import { state } from '/js/admin-state.js?v=064b9b85'; -import { PE_SVG, initInlineEdit, swapContent } from '/js/admin-shared.js?v=a3e8bc29'; -import { createListbox } from '/js/listbox.js?v=9a579bea'; -import { renderWidgetConfigForm } from '/js/widget-config-form.js?v=8b444e40'; +import { PE_SVG, initInlineEdit, swapContent } from '/js/admin-shared.js?v=8fec4e2f'; +import { createListbox } from '/js/listbox.js?v=3e47b403'; +import { renderWidgetConfigForm } from '/js/widget-config-form.js?v=7e48b5b8'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; import { sizesForView, widgetConfigMode, rejectionLines, carriesTypedValues } from '/js/admin-logic.js?v=b3befae4'; import { t } from '/js/i18n.js?v=ed7c5565'; import { focusFirst, q, qi, qa } from '/js/utils.js?v=cfee1271'; -import { iconSvg } from '/js/icon-set.js?v=941db5a1'; +import { iconSvg } from '/js/icon-set.js?v=26655aca'; const SIZES_WITH_ICONS = new Set(['small', 'medium', 'large', 'xlarge']); diff --git a/ui/js/admin.js b/ui/js/admin.js index 8f8085fd..4288f29d 100644 --- a/ui/js/admin.js +++ b/ui/js/admin.js @@ -4,10 +4,10 @@ import { buildSettingsTileForm, captureActLabels, serializeKvRows, -} from '/js/admin-app-form.js?v=896b8d1d'; -import { checkAuth, requireLogin, wirePasswordStrength } from '/js/admin-auth.js?v=6466f02d'; +} from '/js/admin-app-form.js?v=6dfcf64d'; +import { checkAuth, requireLogin, wirePasswordStrength } from '/js/admin-auth.js?v=9b2d4d17'; import { recoveryShown } from '/js/config-recovery.js?v=85138697'; -import { focusRow, initList, render, syncFilterUI } from '/js/admin-list.js?v=a0568eab'; +import { focusRow, initList, render, syncFilterUI } from '/js/admin-list.js?v=9a75dcf8'; import { resolveAdminSection, settleDock } from '/js/admin-logic.js?v=b3befae4'; import { afterImport, @@ -31,7 +31,7 @@ import { wireUnsplashKey, wireProxmox, applyHealthChecks, -} from '/js/admin-settings.js?v=5709194e'; +} from '/js/admin-settings.js?v=60c010a8'; import { apiGet, apiPost, @@ -45,17 +45,17 @@ import { setReauthHandler, toast, withheldText, -} from '/js/admin-shared.js?v=a3e8bc29'; +} from '/js/admin-shared.js?v=8fec4e2f'; import { collapsedFolders, filter, state } from '/js/admin-state.js?v=064b9b85'; -import { buildWidgetForm } from '/js/admin-widget-form.js?v=2427cc08'; -import { initFluidHover } from '/js/fluid-hover.js?v=cb886e86'; +import { buildWidgetForm } from '/js/admin-widget-form.js?v=b0b45771'; +import { initFluidHover } from '/js/fluid-hover.js?v=8b4aadd4'; import { formatNumber } from '/js/format-number.js?v=abcd4ae4'; -import { initGlideSelect, syncGlideSelect } from '/js/glide-select.js?v=8b39e9d0'; -import { createListbox } from '/js/listbox.js?v=9a579bea'; +import { initGlideSelect, syncGlideSelect } from '/js/glide-select.js?v=75a71ab4'; +import { createListbox } from '/js/listbox.js?v=3e47b403'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; import { initI18n, LANGUAGES, t } from '/js/i18n.js?v=ed7c5565'; import { loadLocalIcons } from '/js/icons.js?v=5ac01d72'; -import { ensureSprite, iconSvg } from '/js/icon-set.js?v=941db5a1'; +import { ensureSprite, iconSvg } from '/js/icon-set.js?v=26655aca'; import { clearSkipTls, convert, diff --git a/ui/js/dashboard.js b/ui/js/dashboard.js index 4e57d4a1..0be7ac15 100644 --- a/ui/js/dashboard.js +++ b/ui/js/dashboard.js @@ -29,8 +29,8 @@ import { teardownWidgets, titleWhenTruncated, } from '/js/utils.js?v=cfee1271'; -import { initFluidHover } from '/js/fluid-hover.js?v=cb886e86'; -import { initSpotlight } from '/js/spotlight.js?v=e6c4a09a'; +import { initFluidHover } from '/js/fluid-hover.js?v=8b4aadd4'; +import { initSpotlight } from '/js/spotlight.js?v=06e7ef30'; import { html, setHtml, raw } from '/js/html.js?v=c71f8903'; import { initI18n, t, currentLang } from '/js/i18n.js?v=ed7c5565'; import { blockingScreenFor, showBlockingScreen } from '/js/config-recovery.js?v=85138697'; @@ -64,7 +64,7 @@ import { repeatJittered } from '/js/jitter.js?v=087a1fcf'; import { isMobileLayout, onLayoutChange } from '/js/layout.js?v=e9f4b607'; import { startWakeLock } from '/js/wake-lock.js?v=6b9591cf'; import { applyLabelTones, loadSamplingImage, sampleImage, toneForColor } from '/js/label-contrast.js?v=0b1ebb19'; -import { ensureSprite, iconSvg } from '/js/icon-set.js?v=941db5a1'; +import { ensureSprite, iconSvg } from '/js/icon-set.js?v=26655aca'; import { pageTheme, paletteColor } from '/js/palette.js?v=3fb8ae43'; import { THEME_KEY, applyTheme, prefersDark, readMode, resolveTheme } from '/js/theme.js?v=eeafa4b5'; diff --git a/ui/js/fluid-hover.js b/ui/js/fluid-hover.js index 61233dde..de80972b 100644 --- a/ui/js/fluid-hover.js +++ b/ui/js/fluid-hover.js @@ -1,7 +1,7 @@ /* A single highlight per list, moved instead of redrawn. It follows the pointer, and falls back to the keyboard cursor when the pointer leaves. */ -import { ensurePill, pillOf, placePill, restPill } from '/js/moving-pill.js?v=4e961dec'; +import { ensurePill, pillOf, placePill, restPill } from '/js/moving-pill.js?v=afe204a3'; const FINE = '(hover: hover) and (pointer: fine)'; @@ -35,7 +35,7 @@ function selectable(item) { function stateFor(container) { let s = states.get(container); if (!s) { - s = { hover: null, kb: null, bound: false }; + s = { hover: null, kb: null, bound: false, zoom: null }; states.set(container, s); } return s; @@ -50,7 +50,7 @@ function refresh(container) { if (target && target.isConnected && selectable(target)) { const pill = ensurePill(container, 'fh-hl'); live.add(container); - placePill(container, pill, target); + placePill(container, pill, target, s.zoom ? s.zoom() : 1); } else { restPill(pillOf(container)); live.delete(container); @@ -105,7 +105,9 @@ export function fluidHoverClear(container) { refresh(container); } -export function initFluidHover(root = document) { +/** @param {Document} [root] + @param {{ container: string, item: string, zoom?: () => number }[]} [lists] */ +export function initFluidHover(root = document, lists = LISTS) { if (!fluidHoverSupported()) return; root.addEventListener( 'pointerover', @@ -113,12 +115,14 @@ export function initFluidHover(root = document) { if (e.pointerType && e.pointerType !== 'mouse') return; const from = /** @type {Element} */ (e.target); if (!from || !from.closest) return; - for (const cfg of LISTS) { + for (const cfg of lists) { const item = from.closest(cfg.item); if (!item) continue; const container = item.closest(cfg.container); if (!container || !selectable(item)) continue; - stateFor(container).hover = item; + const s = stateFor(container); + s.hover = item; + s.zoom = cfg.zoom || null; bind(container); refresh(container); return; diff --git a/ui/js/glide-select.js b/ui/js/glide-select.js index 89b18ae7..754091b9 100644 --- a/ui/js/glide-select.js +++ b/ui/js/glide-select.js @@ -4,7 +4,7 @@ Not gated on a pointer: this is selection, not hover, so the phone tab bar gets it too. */ -import { ensurePill, measurable, placePill } from '/js/moving-pill.js?v=4e961dec'; +import { ensurePill, measurable, placePill } from '/js/moving-pill.js?v=afe204a3'; const NAVS = [ { container: '.sb-nav', item: '.nl', holdInk: true }, diff --git a/ui/js/icon-set.js b/ui/js/icon-set.js index f6ae09c9..b725b3e2 100644 --- a/ui/js/icon-set.js +++ b/ui/js/icon-set.js @@ -229,17 +229,22 @@ export const ICONS = { dot(17, 14, T.NODE, 'accent'), ], }, - downloads: { + downloads: { shapes: [line('M12 2v8M8 6l4 4 4-4', 'accent'), rect(3, 12, 18, 9, T.R_MD)] }, + nowplaying: { shapes: [rect(3, 3, 18, 18, T.R_LG), shape('M10 8v8l6-4z', 'accent', { rj: true })] }, + upnext: { inkMask: false, shapes: [ - detail(2, 3, 11, 4, T.R_SM, 'accent'), - detail(15, 3, 7, 4, T.R_SM), - rect(3, 9, 18, 12, T.R_MD), - bar(6, 13, 9), - bar(6, 17, 5), + detail(2, 3, 4, 4, T.R_SM), + detail(8, 3, 2, 4, 1, 'accent'), + detail(12, 4, 10, 2, 1), + detail(2, 10, 4, 4, T.R_SM), + detail(8, 10, 2, 4, 1), + detail(12, 11, 7, 2, 1), + detail(2, 17, 4, 4, T.R_SM), + detail(8, 17, 2, 4, 1), + detail(12, 18, 9, 2, 1), ], }, - nowplaying: { shapes: [rect(3, 3, 18, 18, T.R_LG), shape('M10 8v8l6-4z', 'accent', { rj: true })] }, system: { shapes: [line('M3 16 7 12 11 15 16 8'), dot(19, 6, T.NODE, 'accent')] }, weather: { inkMask: false, diff --git a/ui/js/listbox.js b/ui/js/listbox.js index 85808069..a57dcfd7 100644 --- a/ui/js/listbox.js +++ b/ui/js/listbox.js @@ -7,11 +7,11 @@ which exposes a strip of page canvas and misplaces the menu. */ import { nextActiveIndex, singleChoice } from '/js/admin-logic.js?v=b3befae4'; -import { fluidHoverClear, fluidHoverKb } from '/js/fluid-hover.js?v=cb886e86'; +import { fluidHoverClear, fluidHoverKb } from '/js/fluid-hover.js?v=8b4aadd4'; import { formatList } from '/js/format-number.js?v=abcd4ae4'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; import { qa } from '/js/utils.js?v=cfee1271'; -import { iconSvg } from '/js/icon-set.js?v=941db5a1'; +import { iconSvg } from '/js/icon-set.js?v=26655aca'; const CHEV = iconSvg('chevrons', 22, 'solid', 'dd-chev'); diff --git a/ui/js/moving-pill.js b/ui/js/moving-pill.js index f53b85c4..01a5b4af 100644 --- a/ui/js/moving-pill.js +++ b/ui/js/moving-pill.js @@ -32,8 +32,11 @@ export function measurable(el) { return r.width > 0 && r.height > 0; } -/** @param {Element} container @param {HTMLElement} pill @param {Element} item */ -export function placePill(container, pill, item) { +/** Pass the page's CSS zoom when it has one. Rects include it and styles do + not, so the pill otherwise comes out that much too large and off its row. + @param {Element} container @param {HTMLElement} pill @param {Element} item + @param {number} [z] */ +export function placePill(container, pill, item, z = 1) { if (!measurable(container) || !measurable(item)) return false; const cr = container.getBoundingClientRect(); const ir = item.getBoundingClientRect(); @@ -44,14 +47,14 @@ export function placePill(container, pill, item) { cs.borderBottomRightRadius, cs.borderBottomLeftRadius, ].join(' '); - pill.style.width = ir.width + 'px'; - pill.style.height = ir.height + 'px'; + pill.style.width = ir.width / z + 'px'; + pill.style.height = ir.height / z + 'px'; /* The pill is anchored at the inline start, so right-to-left measures from the right edge and travels the other way. None of these lists scroll sideways, so the rect difference is the whole inline offset. */ const rtl = cs.direction === 'rtl'; - const inline = (rtl ? cr.right - ir.right : ir.left - cr.left) - container.clientLeft; - const block = ir.top - cr.top - container.clientTop + container.scrollTop; + const inline = (rtl ? cr.right - ir.right : ir.left - cr.left) / z - container.clientLeft; + const block = (ir.top - cr.top) / z - container.clientTop + container.scrollTop; pill.style.transform = 'translate3d(' + (rtl ? -inline : inline) + 'px,' + block + 'px,0)'; if (!pill.classList.contains('on')) { /* `.on` carries the transform transition, so it has to land in a later diff --git a/ui/js/spotlight.js b/ui/js/spotlight.js index 2b60e6b2..9074a7df 100644 --- a/ui/js/spotlight.js +++ b/ui/js/spotlight.js @@ -1,4 +1,4 @@ -import { fluidHoverClear, fluidHoverKb } from '/js/fluid-hover.js?v=cb886e86'; +import { fluidHoverClear, fluidHoverKb } from '/js/fluid-hover.js?v=8b4aadd4'; import { mk, clr, el, initial, inp as inpById, q, qa, setUserText } from '/js/utils.js?v=cfee1271'; import { t } from '/js/i18n.js?v=ed7c5565'; diff --git a/ui/js/widget-config-form.js b/ui/js/widget-config-form.js index 12b94d04..fa296b56 100644 --- a/ui/js/widget-config-form.js +++ b/ui/js/widget-config-form.js @@ -3,9 +3,9 @@ import { t } from '/js/i18n.js?v=ed7c5565'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; -import { reveal, wireInlineEdit } from '/js/admin-shared.js?v=a3e8bc29'; -import { createListbox } from '/js/listbox.js?v=9a579bea'; -import { renderColorControl } from '/js/admin-color-control.js?v=e2a0169e'; +import { reveal, wireInlineEdit } from '/js/admin-shared.js?v=8fec4e2f'; +import { createListbox } from '/js/listbox.js?v=3e47b403'; +import { renderColorControl } from '/js/admin-color-control.js?v=d444b337'; import { seedCarried, applyOptionSet, diff --git a/ui/js/widget-glyphs.js b/ui/js/widget-glyphs.js index f4f4db5d..98edf40d 100644 --- a/ui/js/widget-glyphs.js +++ b/ui/js/widget-glyphs.js @@ -1,5 +1,5 @@ // @ts-check -import { iconSvg } from '/js/icon-set.js?v=941db5a1'; +import { iconSvg } from '/js/icon-set.js?v=26655aca'; /* Keys are the glyph names in widget.json files. Renaming one breaks that widget. */ /** @type {Record} */ @@ -16,6 +16,7 @@ const GLYPH_ICON = { merge: 'github', panels: 'switcher', parcel: 'downloads', + calendar: 'upnext', }; /** The glyph names a manifest may declare. */ diff --git a/ui/js/widget-toolbox.js b/ui/js/widget-toolbox.js index 2e8367a0..ee81f0f8 100644 --- a/ui/js/widget-toolbox.js +++ b/ui/js/widget-toolbox.js @@ -581,7 +581,8 @@ const PH_STYLE_ID = 'wt-ph-css'; const PH_CSS = '.wt-ph{display:inline-block;vertical-align:middle;flex:none;border-radius:4px;' + 'background:var(--wt-ph,rgba(235,235,245,0.16))}' + - 'html[data-theme="light"] .wt-ph{background:var(--wt-ph,rgba(60,60,67,0.12))}'; + 'html[data-theme="light"] .wt-ph{background:var(--wt-ph,rgba(60,60,67,0.12))}' + + '@media (forced-colors:active){.wt-ph{forced-color-adjust:none;background:GrayText}}'; /** A soft rounded shape that stands in for content still loading. Set --wt-ph on an ancestor to recolour it for a light surface. diff --git a/ui/test/fluid-hover.test.mjs b/ui/test/fluid-hover.test.mjs index a43bb961..d6caf814 100644 --- a/ui/test/fluid-hover.test.mjs +++ b/ui/test/fluid-hover.test.mjs @@ -323,3 +323,63 @@ test('a detached container is not held', () => { mod.fluidHoverClear(list); assert.equal(mod._liveHolds(list), false, 'the detached list is still held'); }); + +/* A bundled widget page is zoomed to its card. Rects come back zoomed, styles + are applied unzoomed: an unscaled pill is the zoom factor too large. */ +test('a zoomed page places and sizes the pill in its own units', () => { + install(); + const list = fake('ol', { rect: { left: 0, top: 0, width: 400, height: 300 } }); + const row = fake('div', { rect: { left: 0, top: 100, width: 400, height: 80 } }); + row.parent = list; + row._sel = ['.row.clickable']; + list._sel = ['#rows']; + let over; + mod.initFluidHover({ addEventListener: (t, fn) => t === 'pointerover' && (over = fn) }, [ + { container: '#rows', item: '.row.clickable', zoom: () => 2 }, + ]); + over({ pointerType: 'mouse', target: row }); + const pill = list.children[0]; + assert.equal(pill.style.width, '200px'); + assert.equal(pill.style.height, '40px'); + assert.equal(pill.style.transform, 'translate3d(0px,50px,0)'); +}); + +test('an unzoomed list with a fractional width places the pill on its row', () => { + install(); + const list = fake('ol', { rect: { left: 0, top: 0, width: 300.5, height: 6000 } }); + list.offsetWidth = 301; + const row = fake('div', { rect: { left: 0, top: 5000, width: 300.5, height: 52 } }); + row.parent = list; + row._sel = ['.row.clickable']; + list._sel = ['#rows']; + let over; + mod.initFluidHover({ addEventListener: (t, fn) => t === 'pointerover' && (over = fn) }, [ + { container: '#rows', item: '.row.clickable' }, + ]); + over({ pointerType: 'mouse', target: row }); + const pill = list.children[0]; + assert.equal(pill.style.width, '300.5px'); + assert.equal(pill.style.transform, 'translate3d(0px,5000px,0)'); +}); + +test('a list passed to initFluidHover is followed, and the built-in lists are not', () => { + install(); + const own = fake('ol', { rect: { left: 0, top: 0, width: 200, height: 120 } }); + own._sel = ['#rows']; + const row = fake('div', { rect: { left: 0, top: 40, width: 200, height: 40 } }); + row._sel = ['.row.clickable']; + row.parent = own; + const other = fake('ul', { rect: { left: 0, top: 0, width: 200, height: 120 } }); + other._sel = ['.row-dd-list']; + const li = fake('li', { rect: { left: 0, top: 0, width: 200, height: 40 } }); + li._sel = ['li[role="option"]']; + li.parent = other; + let over; + mod.initFluidHover({ addEventListener: (t, fn) => t === 'pointerover' && (over = fn) }, [ + { container: '#rows', item: '.row.clickable' }, + ]); + over({ pointerType: 'mouse', target: row }); + assert.equal(own.children.length, 1); + over({ pointerType: 'mouse', target: li }); + assert.equal(other.children.length, 0); +}); diff --git a/ui/test/upnext-logic.test.mjs b/ui/test/upnext-logic.test.mjs new file mode 100644 index 00000000..3c9ee08f --- /dev/null +++ b/ui/test/upnext-logic.test.mjs @@ -0,0 +1,68 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { dayOffset, kindClass, localDay, visibleItems } from '../widgets/upnext/upnext-logic.js'; + +const at = (y, m, d, h = 12, min = 0) => new Date(y, m - 1, d, h, min); + +test('dayOffset counts local calendar days, not 24-hour spans', () => { + const now = at(2026, 10, 25, 23, 30); + assert.equal(dayOffset(at(2026, 10, 25, 0, 5), now), 0); + assert.equal(dayOffset(at(2026, 10, 26, 0, 5), now), 1); + assert.equal(dayOffset(at(2026, 11, 1, 9), now), 7); +}); + +test('a timed item stays until it ends; a day item for its whole local day', () => { + const now = at(2026, 10, 25, 21, 30); + const items = [ + { + kind: 'episode', + title: 'Airing', + at: at(2026, 10, 25, 21).toISOString(), + day: '', + ends: at(2026, 10, 25, 21, 50).toISOString(), + }, + { + kind: 'episode', + title: 'Over', + at: at(2026, 10, 25, 20).toISOString(), + day: '', + ends: at(2026, 10, 25, 20, 50).toISOString(), + }, + { kind: 'film', title: 'Today', at: null, day: localDay(now), ends: '' }, + { kind: 'album', title: 'Yesterday', at: null, day: localDay(at(2026, 10, 24)), ends: '' }, + { kind: 'film', title: 'Later', at: null, day: '2026-11-02', ends: '' }, + null, + { title: 3 }, + ]; + assert.deepEqual( + visibleItems(items, now).map(i => i.title), + ['Today', 'Airing', 'Later'], + ); + assert.deepEqual(visibleItems('nope', now), []); +}); + +test('kindClass maps every kind to a bar colour class', () => { + assert.equal(kindClass({ kind: 'film' }), 'film'); + assert.equal(kindClass({ kind: 'album' }), 'album'); + assert.equal(kindClass({ kind: 'episode' }), 'episode'); + assert.equal(kindClass({ kind: 'other' }), 'episode'); +}); + +test('rows sort in local time: tonight’s episode comes before tomorrow’s film', () => { + const now = at(2026, 10, 25, 19); + const fromServer = [ + { kind: 'film', title: 'Tomorrow', at: null, day: localDay(at(2026, 10, 26)), ends: '' }, + { + kind: 'episode', + title: 'Tonight', + at: at(2026, 10, 25, 21).toISOString(), + day: '', + ends: at(2026, 10, 25, 22).toISOString(), + }, + { kind: 'album', title: 'Today', at: null, day: localDay(now), ends: '' }, + ]; + assert.deepEqual( + visibleItems(fromServer, now).map(i => i.title), + ['Today', 'Tonight', 'Tomorrow'], + ); +}); diff --git a/ui/widgets/backup/backup.html b/ui/widgets/backup/backup.html index 07737427..8c0b5e13 100644 --- a/ui/widgets/backup/backup.html +++ b/ui/widgets/backup/backup.html @@ -122,7 +122,7 @@
+ + + +
+
    + +
    + + + diff --git a/ui/widgets/upnext/upnext-items.js b/ui/widgets/upnext/upnext-items.js new file mode 100644 index 00000000..ef9e1d32 --- /dev/null +++ b/ui/widgets/upnext/upnext-items.js @@ -0,0 +1,198 @@ +const DAY_MS = 24 * 3600 * 1000; +const MAX_ITEMS = 12; +const DEFAULT_RUNTIME_MIN = 60; +const MAX_RUNTIME_MIN = 24 * 60; +const MAX_TIME = 8.64e15; +/* Keep a day item until its day ends at UTC-12. The browser drops it at the + reader's own midnight. */ +const LAST_ZONE_MS = 12 * 3600 * 1000; +const DATE_ONLY = /^(\d{4}-\d{2}-\d{2})/; +const RELEASES = Object.freeze([ + ['inCinemas', 'cinema'], + ['digitalRelease', 'digital'], + ['physicalRelease', 'physical'], +]); + +const str = v => (typeof v === 'string' ? v.trim() : ''); +const list = v => (Array.isArray(v) ? v.filter(x => x && typeof x === 'object' && !Array.isArray(x)) : []); +const whole = v => (Number.isInteger(v) && v >= 0 ? v : null); +const minutes = v => (Number.isInteger(v) && v > 0 && v <= MAX_RUNTIME_MIN ? v : null); +const ms = v => { + const t = typeof v === 'string' ? Date.parse(v) : NaN; + return Number.isFinite(t) ? t : null; +}; +/* Radarr and Lidarr dates are calendar days sent as midnight UTC. Keep the day, + not the instant, or a reader west of UTC sees the day before. */ +const dayOf = v => { + const m = DATE_ONLY.exec(str(v)); + return m ? m[1] : ''; +}; +const dayStartMs = day => Date.parse(day + 'T00:00:00Z'); +const dayUntil = day => dayStartMs(day) + DAY_MS + LAST_ZONE_MS; +const slugPath = (prefix, slug) => (slug ? `${prefix}/${encodeURIComponent(slug)}` : ''); + +/* "Mon, Oct 26, 2026" as an episode name. Engines parse loose text as dates too, + so only a date near the air date counts. */ +const namesItsDate = (name, at) => { + const d = Date.parse(name); + return Number.isFinite(d) && Math.abs(d - at) < 2 * DAY_MS; +}; + +/** One row. `at` is an ISO instant for a timed item, or null. `day` is the + calendar day for an item with no time. `until` is when it stops being next. */ +function makeItem({ kind, title, at = null, day = '', until, path = '', ...rest }) { + return { kind, title, at, day, until, path, ...rest }; +} + +/** The series of the soonest episodes still to come, at most `limit`, soonest first. + @param {unknown} episodes @param {number} now @param {number} limit @returns {number[]} */ +function sonarrSeriesIds(episodes, now, limit) { + const ids = []; + const coming = list(episodes) + .map(e => ({ id: e.seriesId, t: ms(e.airDateUtc), runtime: minutes(e.runtime) || DEFAULT_RUNTIME_MIN })) + .filter(e => Number.isSafeInteger(e.id) && e.id > 0 && e.t != null && e.t + e.runtime * 60000 > now) + .sort((a, b) => a.t - b.t); + for (const e of coming) { + if (ids.length >= limit) break; + if (!ids.includes(e.id)) ids.push(e.id); + } + return ids; +} + +/** Episodes. Several episodes of one series at one time become one row. An + episode whose series is unknown is left out. + @param {unknown} episodes Sonarr /api/v3/calendar + @param {(e: any) => any} [seriesOf] the series record for an episode */ +function sonarrItems(episodes, seriesOf = e => e.series) { + const rows = []; + for (const e of list(episodes)) { + const found = seriesOf(e); + const s = found && typeof found === 'object' ? found : {}; + const title = str(s.title); + const t = ms(e.airDateUtc); + if (!title || t == null) continue; + /* A daily show numbers by air date, often with the year as its season and the date as its + name. Both repeat the date the row already shows. */ + const daily = str(s.seriesType) === 'daily' || whole(e.seasonNumber) >= 1900; + const season = daily ? null : whole(e.seasonNumber); + const episode = daily ? null : whole(e.episodeNumber); + const runtime = minutes(e.runtime) || minutes(s.runtime) || DEFAULT_RUNTIME_MIN; + const name = str(e.title); + rows.push( + makeItem({ + kind: 'episode', + title, + at: new Date(t).toISOString(), + until: t + runtime * 60000, + season, + episode, + lastEpisode: episode, + name: name && name.toUpperCase() !== 'TBA' && !namesItsDate(name, t) ? name : '', + premiere: episode === 1 && season !== 0, + finale: ['season', 'series'].includes(str(e.finaleType)) ? str(e.finaleType) : '', + path: slugPath('/series', str(s.titleSlug)), + key: `episode|${title.toLowerCase()}|${daily ? t : `${season}|${episode}`}`, + }), + ); + } + rows.sort( + (a, b) => + Date.parse(a.at) - Date.parse(b.at) || a.title.localeCompare(b.title) || (a.episode ?? 0) - (b.episode ?? 0), + ); + const merged = []; + for (const r of rows) { + const prev = merged[merged.length - 1]; + if ( + prev && + prev.title === r.title && + prev.at === r.at && + prev.season === r.season && + prev.lastEpisode != null && + r.episode === prev.lastEpisode + 1 + ) { + prev.lastEpisode = r.episode; + prev.name = ''; + prev.finale = r.finale || prev.finale; + prev.until = Math.max(prev.until, r.until); + continue; + } + merged.push(r); + } + return merged; +} + +/** Films. Each release date that falls in the window is its own row. + @param {unknown} movies Radarr /api/v3/calendar @param {number} from @param {number} to + @param {string[]} [types] release kinds to keep: cinema, digital, physical */ +function radarrItems(movies, from, to, types) { + const keep = new Set(Array.isArray(types) ? types : RELEASES.map(([, k]) => k)); + const out = []; + for (const m of list(movies)) { + const title = str(m.title); + if (!title) continue; + for (const [field, release] of RELEASES) { + if (!keep.has(release)) continue; + const day = dayOf(m[field]); + if (!day) continue; + const start = dayStartMs(day); + if (start + DAY_MS <= from || start > to) continue; + out.push( + makeItem({ + kind: 'film', + title, + day, + until: dayUntil(day), + release, + year: whole(m.year), + path: slugPath('/movie', str(m.titleSlug)), + key: `film|${title.toLowerCase()}|${release}|${day}`, + }), + ); + } + } + return out; +} + +const ALBUM_TYPES = new Set(['album', 'ep', 'single']); +/** Albums. @param {unknown} albums Lidarr /api/v1/calendar with includeArtist */ +function lidarrItems(albums) { + const out = []; + for (const a of list(albums)) { + const title = str(a.title); + const day = dayOf(a.releaseDate); + if (!title || !day) continue; + const artist = a.artist && typeof a.artist === 'object' ? str(a.artist.artistName) : ''; + const type = str(a.albumType).toLowerCase(); + out.push( + makeItem({ + kind: 'album', + title, + day, + until: dayUntil(day), + artist, + albumType: ALBUM_TYPES.has(type) ? type : '', + path: slugPath('/album', str(a.foreignAlbumId)), + key: `album|${title.toLowerCase()}|${artist.toLowerCase()}|${day}`, + }), + ); + } + return out; +} + +const sortKey = i => (i.at ? Date.parse(i.at) : dayStartMs(i.day)); + +/** Every source's rows, ended ones dropped, duplicates from a second instance + removed, soonest first. @param {object[][]} groups @param {number} now */ +function mergeItems(groups, now) { + const seen = new Set(); + const out = []; + for (const i of groups.flat()) { + if (!(i.until > now && i.until <= MAX_TIME) || seen.has(i.key)) continue; + seen.add(i.key); + out.push(i); + } + out.sort((a, b) => sortKey(a) - sortKey(b) || a.title.localeCompare(b.title)); + return out.slice(0, MAX_ITEMS).map(({ key, until, ...rest }) => ({ ...rest, ends: new Date(until).toISOString() })); +} + +module.exports = { sonarrSeriesIds, sonarrItems, radarrItems, lidarrItems, mergeItems, minutes, MAX_ITEMS, DAY_MS }; diff --git a/ui/widgets/upnext/upnext-logic.js b/ui/widgets/upnext/upnext-logic.js new file mode 100644 index 00000000..c493a4bc --- /dev/null +++ b/ui/widgets/upnext/upnext-logic.js @@ -0,0 +1,41 @@ +// @ts-check + +/** @typedef {{ kind: string, title: string, at: string|null, day: string, ends: string }} Item */ + +/** A local calendar day as YYYY-MM-DD. @param {Date} d @returns {string} */ +export function localDay(d) { + const p = (/** @type {number} */ n) => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`; +} + +/** Whole local days from `now` to `d`: 0 today, 1 tomorrow. + @param {Date} d @param {Date} now @returns {number} */ +export function dayOffset(d, now) { + const a = Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()); + const b = Date.UTC(now.getFullYear(), now.getMonth(), now.getDate()); + return Math.round((a - b) / 86400000); +} + +/** Items still to come in the reader's own time zone, soonest first. A timed + item goes once it ends; a day item stays for its whole local day and sorts at + its local start. The server sorts day items at UTC midnight. + @param {unknown} items @param {Date} now @returns {Item[]} */ +export function visibleItems(items, now) { + if (!Array.isArray(items)) return []; + const today = localDay(now); + const when = (/** @type {Item} */ i) => (i.at ? Date.parse(i.at) : new Date(i.day + 'T00:00:00').getTime()); + const kept = items.filter(i => { + if (!i || typeof i !== 'object' || typeof i.title !== 'string') return false; + if (typeof i.at === 'string' && i.at) { + const ends = Date.parse(i.ends); + return Number.isFinite(ends) ? ends > now.getTime() : Date.parse(i.at) > now.getTime(); + } + return typeof i.day === 'string' && i.day >= today; + }); + return kept.sort((a, b) => when(a) - when(b)); +} + +/** @param {Item} i @returns {string} */ +export function kindClass(i) { + return i.kind === 'film' || i.kind === 'album' ? i.kind : 'episode'; +} diff --git a/ui/widgets/upnext/widget.json b/ui/widgets/upnext/widget.json new file mode 100644 index 00000000..bae6da14 --- /dev/null +++ b/ui/widgets/upnext/widget.json @@ -0,0 +1,105 @@ +{ + "name": "upnext", + "label": "widget.label", + "description": "widget.description", + "glyph": "calendar", + "sizes": [ + "small", + "medium", + "large" + ], + "fields": [ + { + "key": "sources", + "type": "group", + "label": "sources.label", + "hint": "sources.hint", + "min": 1, + "max": 6, + "fields": [ + { + "key": "app", + "type": "select", + "variant": "pills", + "label": "app.label", + "default": "sonarr", + "options": [ + { + "value": "sonarr", + "label": "app.opt.sonarr" + }, + { + "value": "radarr", + "label": "app.opt.radarr" + }, + { + "value": "lidarr", + "label": "app.opt.lidarr" + } + ] + }, + { + "key": "url", + "type": "text", + "label": "url.label", + "placeholder": "url.placeholder" + }, + { + "key": "apiKey", + "type": "secret", + "label": "apiKey.label", + "hint": "apiKey.hint" + }, + { + "key": "releases", + "type": "multiselect", + "label": "releases.label", + "default": [ + "cinema", + "digital" + ], + "options": [ + { + "value": "cinema", + "label": "releases.opt.cinema" + }, + { + "value": "digital", + "label": "releases.opt.digital" + }, + { + "value": "physical", + "label": "releases.opt.physical" + } + ], + "showIf": { + "field": "app", + "equals": "radarr" + }, + "cosmetic": true + }, + { + "key": "skipTls", + "type": "toggle", + "label": "skipTls.label", + "default": false + }, + { + "key": "href", + "type": "text", + "label": "href.label", + "hint": "href.hint", + "optional": true, + "cosmetic": true + } + ] + }, + { + "key": "unmonitored", + "type": "toggle", + "label": "unmonitored.label", + "default": false, + "cosmetic": true + } + ] +} diff --git a/ui/widgets/weather/index.html b/ui/widgets/weather/index.html index 4ea02f5b..76dedeab 100644 --- a/ui/widgets/weather/index.html +++ b/ui/widgets/weather/index.html @@ -73,8 +73,8 @@