gitoriaLog in with ident

tracker

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commit12595e4712595e47mission 035: theme re-vendored from layouts.worldapi.org 0222f67 (two corner radii: radiusSmall 5px, radiusLarge 10px); the tracker's 18 own radii -> radiusSmall/radiusLarge (--layout-radius is gone); check-theme 0; gates 379/0, 32/0, 53/0, 229/0, 26/0; real copy: every computed radius in {0, 5px, 10px, 50%}mre12595e47/lib/franchises.hl

23.3 KB

  1. // lib/franchises.hl — FRANCHISES AND TIMELINES (tracker.worldapi.org#19, mission 066). The creator: "we have franchises, and
  2. // timelines, and some franchises have multiple timelines ... make them sortable by timeline or release date at shows the
  3. // release date of the last episode counts. in a show or movie page we show a franchise widget with < Prequel | TimelineName
  4. // | Sequel > and if the timeline is part of a franchise that franchise is above it and when you click on it you get the
  5. // franchise index."
  6. //
  7. // franchisesTable pk @id index !slug { name, slug, shortId (null: not given out yet), summary, created }
  8. // timelinesTable pk @id index !slug { name, slug, franchise (@id | null), order [show @id …], source ('tmdb' |
  9. // 'editor' | 'seed'), tmdbCollection (TMDB collection id | null), orderKind
  10. // ('in-universe' | 'release' | null: not said, tracker#40), supersededBy (@id | null),
  11. // created }
  12. // storage/mpackdb/{franchises,timelines}.db (new tables; nothing in shows.db changes; the seed's collectionchecks.db is
  13. // collections.hl's, the curated seed's franchiseseed.db franchiseseed.hl's)
  14. //
  15. // A TITLE MAY BE IN SEVERAL TIMELINES (Star Trek Prime and Kelvin, or a TMDB collection and a hand-made one): `byShow` (show
  16. // @id → [timeline @id]) is built once at start (`ensureFranchises`, one scan of the timelines) and kept up to date by every
  17. // edit — the show page's widget reads only its own timelines, never all of them.
  18. //
  19. // THE ORDER of a timeline is its in-world order (`order`, editable). The pages can sort it by RELEASE instead: a movie by its
  20. // `release`, a SERIES by the release date of its LAST episode (the newest one already out; none out yet → the newest
  21. // announced one; no episode dates at all → the series' own `release`). Titles without any date go last.
  22. //
  23. // SUPERSEDED (tracker#40): a TMDB collection timeline whose every title is in ONE curated (seed) timeline is hidden — no widget,
  24. // not on /franchises or a franchise's index — but kept (its own page and the editor's finder still reach it): `supersededBy` =
  25. // that curated timeline. Set by the collection seed when it makes one (collections.hl) and by the curated seed's build
  26. // (franchiseseed.hl), which also lifts it when the cover is gone; `seededCoverOf` decides.
  27. //
  28. // PUBLIC LIKE THE OTHER LISTS (mission 054): the pages and the widget's prequel/sequel links show only titles with
  29. // `adult == false` (shows.hl isPublicTitle); the order keeps them, they are just skipped.
  30. //
  31. // THE SEEDS: TMDB movie collections → timelines (collections.hl); the curated franchises of data/franchises.json, movies AND
  32. // series (franchiseseed.hl, tracker#40) — both write through the functions here.
  33. //
  34. // ONE PROCESS PER STORAGE (hybriel#21): the tables are opened here, in the app's process only.
  35. import { MPackDB } from 'hl:mpackdb'
  36. import { env } from 'hl:proc'
  37. import { now } from 'hl:time'
  38. import { storageDir, newId, todayStr, dateOr, firstOf } from './util.hl'
  39. import { showById, seasonsOfShow, episodesOfSeason, titleWithYear, posterUrlOf, isPublicTitle, titlePath } from './shows.hl'
  40. import { userOfSession } from './users.hl'
  41. import { dbSearch } from './search.hl'
  42. import { slugBaseOf } from './search-helpers.hl'
  43. static franchisesTable = new MPackDB(file = storageDir + '/franchises.db', primaryKey = '@id', indexes = ['!slug'])
  44. static timelinesTable = new MPackDB(file = storageDir + '/timelines.db', primaryKey = '@id', indexes = ['!slug'])
  45. // a name as typed: trimmed, null when there is none (the editor's names and summaries)
  46. static trimmedOr = (&v) => { return v != null && hlTypeName(v) == 'String' && v.trim() != '' ? v.trim() : null }
  47. // ---- who may edit: the creator (ident short id az5b2); TRACKER_EDITORS = a comma list of ident ids (the gate sets its own) ----
  48. static editorIds = () => {
  49. v = env('TRACKER_EDITORS')
  50. raw = v != null && v.trim() != '' ? v : 'az5b2'
  51. out = []
  52. for (p of raw.split(',')) { if (p.trim() != '') { out.push(p.trim()) } }
  53. return out
  54. }
  55. static editors = editorIds()
  56. static isEditorSession = (&session) => {
  57. if (session == null || hlTypeName(session) != 'Instance') { return false }
  58. u = userOfSession(session)
  59. return u != null && u.identity != null && editors.includes(u.identity)
  60. }
  61. // a list's index of x, -1 when it is not in it (hand-written: list helpers beyond includes/push are not trusted, hybriel#1)
  62. static posOf = (&list, &x) => {
  63. let i = 0
  64. while (i < list.length) {
  65. if (list[i] == x) { return i }
  66. i = i + 1
  67. }
  68. return -1
  69. }
  70. // ---- slugs: "Star Trek" → "Star-Trek" (the shows' urlSegment form, search-helpers.hl slugBaseOf; none → 'timeline') -------
  71. static freeSlug = (&table, &name) => {
  72. base = slugBaseOf(name, 'timeline')
  73. // 'edit' is the editor's own address (/franchises/edit)
  74. if (firstOf(table.find('slug', base)) == null && base != 'edit') { return base }
  75. let n = 2
  76. while (firstOf(table.find('slug', base + '-' + n)) != null) { n = n + 1 }
  77. return base + '-' + n
  78. }
  79. static franchiseBySlug = (&slug) => {
  80. if (slug == null || hlTypeName(slug) != 'String' || slug == '') { return null }
  81. return firstOf(franchisesTable.find('slug', slug))
  82. }
  83. static timelineBySlug = (&slug) => {
  84. if (slug == null || hlTypeName(slug) != 'String' || slug == '') { return null }
  85. return firstOf(timelinesTable.find('slug', slug))
  86. }
  87. static franchiseById = (&id) => { return id == null || hlTypeName(id) != 'String' || id == '' ? null : franchisesTable.fetch(id) }
  88. static timelineById = (&id) => { return id == null || hlTypeName(id) != 'String' || id == '' ? null : timelinesTable.fetch(id) }
  89. // ---- byShow: show @id → [timeline @id] ------------------------------------------------------------------------------------
  90. static byShow = {}
  91. static state = { built = false timelines = 0 ms = 0 }
  92. static indexAdd = (&showId, &timelineId) => {
  93. let l = byShow[showId]
  94. if (l == null) { l = [] }
  95. if (!l.includes(timelineId)) { l.push(timelineId) }
  96. byShow[showId] = l
  97. return true
  98. }
  99. static indexRemove = (&showId, &timelineId) => {
  100. l = byShow[showId]
  101. if (l == null) { return true }
  102. out = []
  103. for (x of l) { if (x != timelineId) { out.push(x) } }
  104. byShow[showId] = out
  105. return true
  106. }
  107. static ensureFranchises = () => {
  108. if (state.built) { return true }
  109. state.built = true
  110. t0 = now()
  111. let n = 0
  112. for (t of timelinesTable.find(null, null)) {
  113. n = n + 1
  114. if (t.order != null) { for (sid of t.order) { indexAdd(sid, t.id) } }
  115. }
  116. state.timelines = n
  117. state.ms = now() - t0
  118. console.log('franchises: ' + n + ' timelines, ' + franchisesTable.count() + ' franchises (' + state.ms + ' ms)')
  119. return true
  120. }
  121. static timelineIdsOfShow = (&showId) => {
  122. ensureFranchises()
  123. l = byShow[showId]
  124. return l != null ? l : []
  125. }
  126. // ---- a title as the pages list it ------------------------------------------------------------------------------------------
  127. // the date the release sort uses: a movie's release; a series' LAST episode (newest out ≤ today, else newest announced, else
  128. // its own release)
  129. static releaseKeyOf = (&show) => {
  130. if (show.type != 'series') { return dateOr(show.release) }
  131. today = todayStr()
  132. let lastOut = ''
  133. let lastAny = ''
  134. for (s of seasonsOfShow(show)) {
  135. for (e of episodesOfSeason(s)) {
  136. let d = dateOr(e.release)
  137. if (d != '') {
  138. if (d > lastAny) { lastAny = d }
  139. if (d <= today && d > lastOut) { lastOut = d }
  140. }
  141. }
  142. }
  143. if (lastOut != '') { return lastOut }
  144. if (lastAny != '') { return lastAny }
  145. return dateOr(show.release)
  146. }
  147. static itemOf = (&show, pos) => {
  148. series = show.type == 'series'
  149. d = releaseKeyOf(show)
  150. label = d == '' ? 'no date' : (series ? 'last episode ' + d : d)
  151. return { id = show.id pos = pos title = titleWithYear(show) href = titlePath(show) posterUrl = posterUrlOf(show)
  152. kind = series ? 'Series' : 'Movie' date = d dateLabel = label }
  153. }
  154. // the public titles of a timeline, in its order (`pos` = the place in the in-world order, 1-based)
  155. static itemsOf = (&t) => {
  156. out = []
  157. let pos = 0
  158. if (t.order == null) { return out }
  159. for (sid of t.order) {
  160. let s = showById(sid)
  161. if (s != null && isPublicTitle(s)) {
  162. pos = pos + 1
  163. out.push(itemOf(s, pos))
  164. }
  165. }
  166. return out
  167. }
  168. // release order: oldest first, no date last; equal dates keep the timeline order (a stable insertion)
  169. static byReleaseOf = (&items) => {
  170. let out = []
  171. for (it of items) {
  172. let next = []
  173. let done = false
  174. for (x of out) {
  175. if (!done && it.date != '' && (x.date == '' || it.date < x.date)) { next.push(it) done = true }
  176. next.push(x)
  177. }
  178. if (!done) { next.push(it) }
  179. out = next
  180. }
  181. return out
  182. }
  183. // tracker#40: what the timeline's own order is, said beside its count ("23 titles · in-universe order"); none when not known
  184. static orderNoteOf = (&t) => {
  185. if (t.orderKind == 'in-universe') { return ' · in-universe order' }
  186. if (t.orderKind == 'release') { return ' · release order' }
  187. return ''
  188. }
  189. static blockOf = (&t) => {
  190. items = itemsOf(t)
  191. return { id = t.id name = t.name slug = t.slug href = '/timelines/' + t.slug items = items byRelease = byReleaseOf(items)
  192. countText = (items.length == 1 ? '1 title' : items.length + ' titles') + orderNoteOf(t) first = items.length > 0 ? byReleaseOf(items)[0].date : '' }
  193. }
  194. // ---- the pages -------------------------------------------------------------------------------------------------------------
  195. // /franchises: every franchise (name, how many timelines), then the timelines WITHOUT one (TMDB's collections, by name)
  196. static byNameOf = (&rows) => {
  197. let out = []
  198. for (r of rows) {
  199. let next = []
  200. let done = false
  201. let k = r.sortName
  202. for (x of out) {
  203. if (!done && k < x.sortName) { next.push(r) done = true }
  204. next.push(x)
  205. }
  206. if (!done) { next.push(r) }
  207. out = next
  208. }
  209. return out
  210. }
  211. static franchiseIndex = () => {
  212. counts = {}
  213. loose = []
  214. for (t of timelinesTable.find(null, null)) {
  215. let n = t.order != null ? t.order.length : 0
  216. // tracker#40: a superseded collection timeline is not listed (nor counted for its franchise)
  217. if (t.supersededBy != null) { n = 0 }
  218. else if (t.franchise != null && t.franchise != '') { counts[t.franchise] = (counts[t.franchise] != null ? counts[t.franchise] : 0) + 1 }
  219. else if (n > 0) { loose.push({ id = t.id name = t.name href = '/timelines/' + t.slug countText = n == 1 ? '1 title' : n + ' titles' sortName = ('' + t.name).toLowerCase() }) }
  220. }
  221. fr = []
  222. for (f of franchisesTable.find(null, null)) {
  223. let c = counts[f.id] != null ? counts[f.id] : 0
  224. fr.push({ id = f.id name = f.name href = '/franchises/' + f.slug summary = f.summary != null ? f.summary : ''
  225. countText = c == 1 ? '1 timeline' : c + ' timelines' sortName = ('' + f.name).toLowerCase() })
  226. }
  227. return { franchises = byNameOf(fr) timelines = byNameOf(loose) }
  228. }
  229. // /franchises/<slug>: the franchise and its timelines (oldest first by their first release) — or null
  230. static franchisePage = (slug) => {
  231. f = franchiseBySlug(slug)
  232. if (f == null) { return null }
  233. let blocks = []
  234. for (t of timelinesTable.find(null, null)) {
  235. if (t.franchise == f.id && t.supersededBy == null) {
  236. let b = blockOf(t)
  237. let next = []
  238. let done = false
  239. for (x of blocks) {
  240. if (!done && b.first != '' && (x.first == '' || b.first < x.first)) { next.push(b) done = true }
  241. next.push(x)
  242. }
  243. if (!done) { next.push(b) }
  244. blocks = next
  245. }
  246. }
  247. return { kind = 'franchise' name = f.name slug = f.slug summary = f.summary != null ? f.summary : '' franchiseName = '' franchiseHref = '' blocks = blocks editHref = '' }
  248. }
  249. // /timelines/<slug>: one timeline, its franchise above it — or null
  250. static timelinePage = (slug) => {
  251. t = timelineBySlug(slug)
  252. if (t == null) { return null }
  253. f = franchiseById(t.franchise)
  254. return { kind = 'timeline' name = t.name slug = t.slug summary = '' franchiseName = f != null ? f.name : '' franchiseHref = f != null ? '/franchises/' + f.slug : ''
  255. blocks = [blockOf(t)] editHref = '/timelines/' + t.slug + '/edit' }
  256. }
  257. // ---- the widget on a show / movie page: one per timeline the title is in ----------------------------------------------------
  258. // { id, name, href, hasFranchise, franchiseName, franchiseHref, hasPrev, noPrev, prevHref, prevTitle, hasNext, noNext, … }:
  259. // prequel / sequel = the nearest PUBLIC title before / after this one in the timeline's order
  260. static widgetsOf = (&showId) => {
  261. out = []
  262. if (showId == null) { return out }
  263. for (tid of timelineIdsOfShow(showId)) {
  264. let t = timelinesTable.fetch(tid)
  265. if (t != null && t.supersededBy == null && t.order != null && t.order.includes(showId)) {
  266. let at = posOf(t.order, showId)
  267. let prev = null
  268. let i = at - 1
  269. while (prev == null && i >= 0) {
  270. let s = showById(t.order[i])
  271. if (s != null && isPublicTitle(s)) { prev = s }
  272. i = i - 1
  273. }
  274. let next = null
  275. let j = at + 1
  276. while (next == null && j < t.order.length) {
  277. let s = showById(t.order[j])
  278. if (s != null && isPublicTitle(s)) { next = s }
  279. j = j + 1
  280. }
  281. let f = franchiseById(t.franchise)
  282. out.push({ id = t.id name = t.name href = '/timelines/' + t.slug
  283. hasFranchise = f != null franchiseName = f != null ? f.name : '' franchiseHref = f != null ? '/franchises/' + f.slug : ''
  284. hasPrev = prev != null noPrev = prev == null prevHref = prev != null ? titlePath(prev) : '' prevTitle = prev != null ? titleWithYear(prev) : ''
  285. hasNext = next != null noNext = next == null nextHref = next != null ? titlePath(next) : '' nextTitle = next != null ? titleWithYear(next) : '' })
  286. }
  287. }
  288. return out
  289. }
  290. // ---- the editor (the creator only — every caller checks isEditorSession first) ----------------------------------------------
  291. static createFranchise = (name, summary) => {
  292. n = trimmedOr(name)
  293. if (n == null || n.length > 120) { return { error = 'a franchise needs a name (at most 120 characters)' } }
  294. s = trimmedOr(summary)
  295. rec = { id = newId() name = n slug = freeSlug(franchisesTable, n) shortId = null summary = s != null ? s : '' created = now() }
  296. if (franchisesTable.put(rec) == null) { return { error = 'not stored: ' + franchisesTable.lastError() } }
  297. franchisesTable.persist()
  298. console.log('franchises: new franchise "' + n + '" /franchises/' + rec.slug)
  299. return { ok = true slug = rec.slug id = rec.id }
  300. }
  301. // a new timeline (the editor's, the collection seed's with its collection id, the curated seed's with its order kind)
  302. static putTimeline = (name, &franchiseId, order, source, &collection, &orderKind) => {
  303. rec = { id = newId() name = name slug = freeSlug(timelinesTable, name) franchise = franchiseId order = order source = source
  304. tmdbCollection = collection orderKind = orderKind created = now() }
  305. if (timelinesTable.put(rec) == null) { return null }
  306. for (sid of order) { indexAdd(sid, rec.id) }
  307. return rec
  308. }
  309. static createTimeline = (&name) => {
  310. n = trimmedOr(name)
  311. if (n == null || n.length > 120) { return { error = 'a timeline needs a name (at most 120 characters)' } }
  312. rec = putTimeline(n, null, [], 'editor', null, null)
  313. if (rec == null) { return { error = 'not stored: ' + timelinesTable.lastError() } }
  314. timelinesTable.persist()
  315. console.log('franchises: new timeline "' + n + '" /timelines/' + rec.slug)
  316. return { ok = true slug = rec.slug id = rec.id }
  317. }
  318. // the editor's view of one timeline: every title in its order (adult ones too — the creator sees what is there)
  319. static editRowsOf = (&t) => {
  320. out = []
  321. if (t == null || t.order == null) { return out }
  322. let i = 0
  323. last = t.order.length - 1
  324. for (sid of t.order) {
  325. let s = showById(sid)
  326. if (s != null) {
  327. out.push({ id = s.id pos = i + 1 title = titleWithYear(s) posterUrl = posterUrlOf(s) typeLabel = s.type == 'series' ? 'Series' : 'Movie'
  328. upClass = i == 0 ? 'quiet mv off' : 'quiet mv' downClass = i == last ? 'quiet mv off' : 'quiet mv' })
  329. }
  330. i = i + 1
  331. }
  332. return out
  333. }
  334. static chipsOf = (&t) => {
  335. out = [{ id = '' name = 'None' cls = t == null || t.franchise == null || t.franchise == '' ? 'chip on' : 'chip' }]
  336. list = []
  337. for (f of franchisesTable.find(null, null)) { list.push({ id = f.id name = f.name cls = t != null && t.franchise == f.id ? 'chip on' : 'chip' sortName = ('' + f.name).toLowerCase() }) }
  338. for (c of byNameOf(list)) { out.push(c) }
  339. return out
  340. }
  341. static editPageOf = (slug) => {
  342. t = timelineBySlug(slug)
  343. if (t == null) { return { found = false id = '' name = '' slug = '' viewHref = '' rows = [] chips = [] } }
  344. return { found = true id = t.id name = t.name slug = t.slug viewHref = '/timelines/' + t.slug rows = editRowsOf(t) chips = chipsOf(t) }
  345. }
  346. static storeOrder = (t, order) => {
  347. old = t.order != null ? t.order : []
  348. rec = t
  349. rec.order = order
  350. if (timelinesTable.update(rec.id, rec) == null) { return { error = 'not stored: ' + timelinesTable.lastError() } }
  351. timelinesTable.persist()
  352. for (sid of old) { if (!order.includes(sid)) { indexRemove(sid, rec.id) } }
  353. for (sid of order) { indexAdd(sid, rec.id) }
  354. return { ok = true rows = editRowsOf(rec) }
  355. }
  356. // tracker#31 (merge.hl): a duplicate title merged into its keeper — in every timeline that lists it, the keeper takes its place
  357. // (once: a timeline that already lists the keeper just drops it) → the number of timelines changed
  358. static titleMerged = (&fromId, &toId) => {
  359. ensureFranchises()
  360. let n = 0
  361. tids = []
  362. if (byShow[fromId] != null) { for (x of byShow[fromId]) { tids.push(x) } }
  363. for (tid of tids) {
  364. let t = timelinesTable.fetch(tid)
  365. if (t != null && t.order != null) {
  366. let order = []
  367. for (sid of t.order) {
  368. let want = sid == fromId ? toId : sid
  369. if (!order.includes(want)) { order.push(want) }
  370. }
  371. let r = storeOrder(t, order)
  372. if (r.error != null) { console.log('merge: timeline ' + tid + ': ' + r.error) } else { n = n + 1 }
  373. }
  374. }
  375. return n
  376. }
  377. static addToTimeline = (&timelineId, &showId) => {
  378. t = timelineById(timelineId)
  379. if (t == null) { return { error = 'no such timeline' } }
  380. if (showById(showId) == null) { return { error = 'no such title' } }
  381. order = []
  382. for (sid of t.order) { order.push(sid) }
  383. if (order.includes(showId)) { return { ok = true rows = editRowsOf(t) } }
  384. order.push(showId)
  385. return storeOrder(t, order)
  386. }
  387. static removeFromTimeline = (&timelineId, &showId) => {
  388. t = timelineById(timelineId)
  389. if (t == null) { return { error = 'no such timeline' } }
  390. order = []
  391. for (sid of t.order) { if (sid != showId) { order.push(sid) } }
  392. return storeOrder(t, order)
  393. }
  394. // one place up (delta -1) or down (+1); at an end nothing changes
  395. static moveInTimeline = (&timelineId, &showId, &delta) => {
  396. t = timelineById(timelineId)
  397. if (t == null) { return { error = 'no such timeline' } }
  398. at = posOf(t.order, showId)
  399. to = at + (delta < 0 ? -1 : 1)
  400. if (at < 0 || to < 0 || to >= t.order.length) { return { ok = true rows = editRowsOf(t) } }
  401. order = []
  402. let i = 0
  403. while (i < t.order.length) {
  404. if (i == at) { order.push(t.order[to]) } else if (i == to) { order.push(t.order[at]) } else { order.push(t.order[i]) }
  405. i = i + 1
  406. }
  407. return storeOrder(t, order)
  408. }
  409. static setTimelineFranchise = (&timelineId, franchiseId) => {
  410. t = timelineById(timelineId)
  411. if (t == null) { return { error = 'no such timeline' } }
  412. let fid = null
  413. if (franchiseId != null && franchiseId != '') {
  414. if (franchiseById(franchiseId) == null) { return { error = 'no such franchise' } }
  415. fid = franchiseId
  416. }
  417. rec = t
  418. rec.franchise = fid
  419. if (timelinesTable.update(rec.id, rec) == null) { return { error = 'not stored: ' + timelinesTable.lastError() } }
  420. timelinesTable.persist()
  421. return { ok = true chips = chipsOf(rec) }
  422. }
  423. // the editor's title search: our database (search.hl's index), the titles not in the timeline yet
  424. static editSearch = (&timelineId, text) => {
  425. t = timelineById(timelineId)
  426. have = t != null && t.order != null ? t.order : []
  427. out = []
  428. r = dbSearch(text != null ? '' + text : '')
  429. for (x of r.titles) {
  430. let s = showById(x.id)
  431. if (s != null && !have.includes(s.id)) { out.push({ id = s.id title = titleWithYear(s) posterUrl = posterUrlOf(s) typeLabel = s.type == 'series' ? 'Series' : 'Movie' }) }
  432. }
  433. return { text = text rows = out note = r.tooShort ? 'Type at least 2 letters.' : (out.length == 0 ? 'Nothing found.' : '') }
  434. }
  435. // the editor's timeline finder: by name, at most 30
  436. static findTimelines = (&text) => {
  437. q = text != null ? ('' + text).trim().toLowerCase() : ''
  438. out = []
  439. if (q.length < 2) { return out }
  440. for (t of timelinesTable.find(null, null)) {
  441. if (out.length < 30 && ('' + t.name).toLowerCase().includes(q)) {
  442. let n = t.order != null ? t.order.length : 0
  443. let hidden = t.supersededBy != null ? ' · hidden: all in ' + nameOfTimeline(t.supersededBy) : ''
  444. out.push({ id = t.id name = t.name href = '/timelines/' + t.slug + '/edit' countText = (n == 1 ? '1 title' : n + ' titles') + hidden })
  445. }
  446. }
  447. return out
  448. }
  449. // ---- the curated seed's reads and writes (franchiseseed.hl, tracker#40) ----------------------------------------------------
  450. // a franchise / timeline by its name, any case — or null (the creator's own of that name is adopted by the seed, never doubled)
  451. static franchiseByName = (&name) => {
  452. q = ('' + name).toLowerCase()
  453. let hit = null
  454. for (f of franchisesTable.find(null, null)) { if (hit == null && ('' + f.name).toLowerCase() == q) { hit = f } }
  455. return hit
  456. }
  457. static timelineByName = (&name) => {
  458. q = ('' + name).toLowerCase()
  459. let hit = null
  460. for (t of timelinesTable.find(null, null)) { if (hit == null && ('' + t.name).toLowerCase() == q) { hit = t } }
  461. return hit
  462. }
  463. // the timeline the collection seed made from TMDB collection `collection`, or null
  464. static timelineOfCollection = (&collection) => {
  465. let hit = null
  466. for (t of timelinesTable.find(null, null)) { if (hit == null && t.tmdbCollection != null && '' + t.tmdbCollection == '' + collection) { hit = t } }
  467. return hit
  468. }
  469. // a seeded timeline rewritten (the data file changed, or titles were imported since): its order and franchise in one update
  470. static storeSeeded = (t, order, &franchiseId) => {
  471. old = t.order != null ? t.order : []
  472. rec = t
  473. rec.order = order
  474. rec.franchise = franchiseId
  475. if (timelinesTable.update(rec.id, rec) == null) { return { error = 'not stored: ' + timelinesTable.lastError() } }
  476. for (sid of old) { if (!order.includes(sid)) { indexRemove(sid, rec.id) } }
  477. for (sid of order) { indexAdd(sid, rec.id) }
  478. return { ok = true }
  479. }
  480. static persistFranchises = () => {
  481. franchisesTable.persist()
  482. timelinesTable.persist()
  483. return true
  484. }
  485. // ---- superseded collection timelines (tracker#40) --------------------------------------------------------------------------------
  486. static nameOfTimeline = (&id) => {
  487. t = timelineById(id)
  488. return t != null ? t.name : '?'
  489. }
  490. // for a collection's titles: `cover` = the curated (seed) timeline holding ALL of them, or null; `franchise` = the cover's
  491. // franchise, else the franchise of a curated timeline holding at least ONE of them (a partly covered collection joins it), or null
  492. static seededCoverOf = (&order) => {
  493. let whole = null
  494. let part = null
  495. for (t of timelinesTable.find(null, null)) {
  496. if (t.source == 'seed' && t.order != null) {
  497. let all = order.length > 0
  498. let any = false
  499. for (sid of order) { if (t.order.includes(sid)) { any = true } else { all = false } }
  500. if (all && whole == null) { whole = t }
  501. if (any && part == null && t.franchise != null && t.franchise != '') { part = t.franchise }
  502. }
  503. }
  504. // (locals not named like the answer's entries: inside a record literal an entry shadows that name for the entries after it)
  505. return { cover = whole != null ? whole.id : null franchise = whole != null && whole.franchise != null ? whole.franchise : part }
  506. }
  507. // hidden behind `byId` (null: shown again)
  508. static setSuperseded = (t, &byId) => {
  509. rec = t
  510. rec.supersededBy = byId
  511. if (timelinesTable.update(rec.id, rec) == null) { return { error = 'not stored: ' + timelinesTable.lastError() } }
  512. return { ok = true }
  513. }

Branches

Latest commits

  • 12595e47mission 035: theme re-vendored from layouts.worldapi.org 0222f67 (two corner radii: radiusSmall 5px, radiusLarge 10px); the tracker's 18 own radii -> radiusSmall/radiusLarge (--layout-radius is gone); check-theme 0; gates 379/0, 32/0, 53/0, 229/0, 26/0; real copy: every computed radius in {0, 5px, 10px, 50%}mre
  • e7305014tracker: report 032 (art + photos)mre
  • fbb903cctracker#33/#37 (mission 032): a title without a TMDB poster gets its backdrop (w780, posterFromBackdrop, shown 2:3 centre-cropped); movies store runtime, the page shows release date + runtime; art backfill (public titles without poster file / movies without runtime) and person photo backfill (tmdbProfile / photoCheck) as the last start jobs (TRACKER_ART, TRACKER_PHOTOS; off in every gate start); gates 379/0, 32/0, 53/0, 229/0, 26/0, check-theme 0; live copy: art 4977 titles in 42 min (115 posters, 15 backdrops, 4609 runtimes), The Remaining shows its backdrop + 7 minmre
  • 4ecc67b2tracker: STATUS/LOG for the t38 + t40 merge (gates 374/0, 32/0, 53/0, 229/0, 26/0, check-theme 0; live copy checks)mre
  • e5945d2fMerge t40 (tracker#40 curated franchises, Franchises menu, superseded collections) into main: deploy.sh lists all six gates (browser, kinds, franchises, pager, franchiseseed, check-theme); search.hl keeps #37's personPhotoOf + #40's importWithCredits; pager gate runs with TRACKER_FRANCHISE_SEED=0; gates 374/0, 32/0, 53/0, 229/0, 26/0, check-theme 0mre
  • b638e99dMerge t38 (tracker#38 pagination, #35 Returning/Airing label) into main: LOG/STATUS keep both sides; pager gate follows #37's /people (everyone, last updated first: seed updatedAt); gates pager 229/0, browser 374/0mre
  • f8ffa4dbtracker: report 032 + The Remaining + #39mre
  • 7565a863tracker: LOG timemre
  • b10f00c8tracker#39: double episodes — migrated episodes whose TMDB id TMDB replaced are adopted by their number in the sync (old id -> migratedTmdbId); merge.hl step 3 merges each season's doubles at start (keeper: most watches > synced > first; watches moved/parked; tombstones into mergedEpisodes, nothing deleted); tools/count-duplicate-episodes.hl; gate fixture + paths-m039; live copy 850 -> 0 in 64 s; gates 373/0, 32/0, 52/0mre
  • 8f1d4542tracker#40: superseded collections — a TMDB collection timeline whose titles are all in one curated timeline is hidden (supersededBy; kept: own page + editor finder), set by the collection seed when it makes one and by the curated build (lifted when the cover is gone); partly covered ones join that franchise; no second widget (First Contact: only Star Trek — Prime); gate franchiseseed 26/0, browser 365/0, kinds 32/0, franchises 53/0, check-theme 0; real copy 24 supersededmre
  • 9448d643tracker#35 follow-up (mission 033): TVmaze 'Running' is labelled 'Returning', or 'Airing' while a non-special episode of the two newest seasons is released within today +-7 days (data unchanged); gate fixtures Running/Airing/Aired + a special; browser 366/0, kinds 32/0, franchises 52/0, pager 229/0, check-theme 0mre
  • 7d4b293dtracker#40: "Franchises" in the main menu (desktop header after People, phone sidebar) → /franchises, marked on franchise and timeline pages; gates 365/0, 32/0, 53/0, 24/0, check-theme 0mre
  • 8751adb8tracker: report 032mre
  • 9bce1f65tracker mission 032: STATUS gate files + the hour-boundary flakemre
  • 718bfb89tracker#37 (mission 032): /people = everyone, last updated first (updatedAt stamped by the person fill; view built at boot, touched people first at once), photo + name tiles (person colour) with the /movies pagination, /people/<letter> removed; photo = our file, tmdbProfile, a cast/crew entry's profile (in-memory map at boot), else the new 'no photo' placeholder; new cast/crew/created_by people keep tmdbProfile; search people rows with the photo; /settings = the heading only; util.hl sortDesc starts from sorted runs (same result, 105k: 1.6 s -> 0.15 s); gates 369/0, 32/0, 52/0, check-theme 0; README/STATUS/LOGmre
  • 93dfb0batracker#40 (mission 034): the curated franchises — data/franchises.json (17 franchises, 31 timelines, 285 TMDB titles, movies + series, in-universe/release order, 12 TMDB collections attached); lib/franchiseseed.hl + jobs.hl franchiseSeedTick (last start job, imports missing titles via details.hl importWithCredits = the search's Add, one per step paced, then one build; franchiseseed.db: editor changes win, the creator's same-name franchise adopted / timeline left alone, 404 remembered, resumable, idempotent); timeline heads 'N titles · in-universe order' (orderKind) and wrap on a phone; series pages show the widget; new gate tests/franchiseseed.mjs (5th in deploy.sh), the others run with TRACKER_FRANCHISE_SEED=0; gates 365/0, 32/0, 52/0, 24/0, check-theme 0; real copy 196 imported, 0 failed, 7 min, restart unchanged=31mre
  • 03ec792ftracker#38 (mission 033): pagination goes exactly to the clicked page — tilelist read the clicked button's text after pagination.hl's own handler had rebuilt the buttons (real clicks only); now li.current, else the button's own text; new gate tests/pager.mjs (5 lists x 11 pages, 390/1280, real + script clicks, Back/Forward) 229/0; browser 365/0, kinds 32/0, franchises 52/0, check-theme 0mre
  • 96ba683adeploy.sh: a gate without a 'passed,' line (check-theme) no longer ends the scriptmre
  • eb3b9205tracker: report 031mre
  • 9b5d2e89tracker mission 031: README (What it does, Test: four gates + the #32 checks, Files: theme/, new pages), STATUS (real copy, A/B load, how to repeat, open points), LOGmre