From 3dbebfbc9345d2603908f32c0dabebc0ff21feb3 Mon Sep 17 00:00:00 2001 From: srdusr <99972264+srdusr@users.noreply.github.com> Date: Sun, 14 Dec 2025 09:24:00 +0200 Subject: Add community text submissions, and make custom text usable for study Submissions - POST /api/texts proposes a passage; nothing reaches players until a moderator approves it. GET /api/texts serves the approved set, which the client merges on top of its bundled packs at startup. - Validation the server enforces rather than trusts: category from a fixed list, 40 to 600 characters, no control characters (a newline makes a passage untypeable in a single-line input), attribution length, and a unique index on md5(lower(btrim(content))) so the same passage cannot be submitted twice under different whitespace or casing. - Moderation is a flag on users. The queue and the review endpoint both refuse a non-moderator, and reviewing an already-reviewed submission is a 404 rather than a silent second write. - Submissions are rate limited per user: enough for a real contributor, not enough to fill the queue from a script. - A Contribute screen carries the form, your own submissions with their status, and - for moderators only - the review queue. This is the half of TypeRacer's model the packs could not reach by authoring: their corpus is roughly twelve thousand passages, grown by submission. Custom text as a study tool - Imported documents are kept between visits, with how far through each one you are. Custom text lived only in memory, so importing a set of notes and reloading the page lost them - fine for pasting a paragraph to race, useless for working through a file over several sittings. - Position is recorded when a segment is finished, not when the next is started, so closing the tab after a segment does not lose it. - Markdown is chunked as markdown: fenced code blocks are kept whole and typed line by line, and the decoration - hashes, asterisks, backticks, link brackets, table pipes - is stripped so what you retype is the material rather than the punctuation around it. - Everything stays on the device. Notes are not uploaded anywhere. Fixed while doing it: a chunk could contain a newline, which cannot be typed in a single-line input at all. Any paragraph with a line break inside it -- ordinary in notes and in wrapped prose - produced an unfinishable segment. Whitespace inside a chunk is now flattened. --- web/src/app.js | 53 +++++++++++- web/src/customLibrary.js | 86 +++++++++++++++++++ web/src/customText.js | 85 ++++++++++++++++++- web/src/screens/contributeScreen.js | 162 ++++++++++++++++++++++++++++++++++++ web/src/screens/mainMenu.js | 34 +++++++- web/src/siteFooter.js | 5 +- web/src/styles.css | 129 ++++++++++++++++++++++++++++ 7 files changed, 546 insertions(+), 8 deletions(-) create mode 100644 web/src/customLibrary.js create mode 100644 web/src/screens/contributeScreen.js (limited to 'web/src') diff --git a/web/src/app.js b/web/src/app.js index 55686be..123ad6f 100644 --- a/web/src/app.js +++ b/web/src/app.js @@ -11,7 +11,10 @@ import { renderMultiplayerScreen } from './screens/multiplayerScreen.js'; import { renderLyricsScreen } from './screens/lyricsScreen.js'; import { renderPublicProfileScreen } from './screens/publicProfileScreen.js'; import { renderStoreScreen } from './screens/storeScreen.js'; +import { renderContributeScreen } from './screens/contributeScreen.js'; import { createGame, freeGame } from './game.js'; +import { api } from './api.js'; +import { saveDocument, setPosition, getDocument, listDocuments, removeDocument } from './customLibrary.js'; import { getSettings } from './settings.js'; import { generateWordStream, generateWeakKeyStream, wordCountForDuration } from './wordGenerator.js'; import { getWeakChars } from './keyStats.js'; @@ -42,6 +45,22 @@ export function startApp(root, localTexts) { let game = null; let cleanupScreen = null; + // Approved community submissions, merged on top of the bundled dataset. + // Best-effort: the app is fully usable on the packs it ships with, so a + // server that is down or unreachable must not delay or break startup. + api.get('/api/texts') + .then(items => { + if (!Array.isArray(items) || items.length === 0) return; + allTexts = allTexts.concat(items.map(t => ({ + category: t.category, + content: t.content, + attribution: t.attribution || undefined, + language: t.language || undefined, + }))); + categories = uniqueCategories(allTexts); + }) + .catch(() => {}); + const onlineUrl = window.TYPERPUNK_TEXTS_URL; if (onlineUrl) { fetch(onlineUrl, { cache: 'no-store' }) @@ -114,8 +133,25 @@ export function startApp(root, localTexts) { startGame(); }, customText, + // Documents are kept so a set of notes survives a reload - see + // customLibrary.js. Purely local; nothing is uploaded. + onOpenDocument: id => { + const doc = getDocument(id); + if (!doc) return; + const parsed = parseCustomContent(doc.raw, doc.name); + customText = { name: doc.name, docId: doc.id, ...parsed }; + session = { type: 'custom', customIndex: Math.min(doc.position || 0, parsed.chunks.length - 1) }; + startGame(); + }, + documents: listDocuments(), + onRemoveDocument: id => { removeDocument(id); showMainMenu(); }, onLoadCustom: loaded => { - customText = loaded; + const doc = saveDocument({ + name: loaded.name, + raw: loaded.raw ?? '', + chunkCount: loaded.chunks.length, + }); + customText = { ...loaded, docId: doc.id }; showMainMenu(); }, onClearCustom: () => { @@ -136,6 +172,7 @@ export function startApp(root, localTexts) { onShowStore: showStore, onShowLyrics: showLyrics, onShowPrivacy: showPrivacy, + onShowContribute: showContribute, onSimulateTest: simulateTest, }); } @@ -175,6 +212,12 @@ export function startApp(root, localTexts) { // Written from what the code actually does, not from a template: this app // has no analytics, no third-party scripts and no npm runtime dependencies, // so there is genuinely very little to disclose. + function showContribute() { + teardown(); + setScreen('contribute'); + cleanupScreen = renderContributeScreen(root, { onBack: showMainMenu, onShowStats: showStats, onShowPlaceholder: showPlaceholder, onShowAccount: showAccount, onShowLeaderboard: showLeaderboard, onShowFriends: showFriends, onShowMultiplayer: showMultiplayer, onShowStore: showStore }); + } + function showPrivacy() { showPlaceholder('Privacy', [ 'On this device, in your browser: your theme, typing settings, personal bests, lifetime stats and per-key accuracy. Clearing site data removes all of it. Nothing here is sent anywhere unless you sign in.', @@ -339,6 +382,7 @@ export function startApp(root, localTexts) { if (session.type === 'custom' && customText) { if (session.customIndex + 1 < customText.chunks.length) { session.customIndex += 1; + if (customText.docId) setPosition(customText.docId, session.customIndex); } else { showMainMenu(); return; @@ -348,6 +392,13 @@ export function startApp(root, localTexts) { } function showEndScreen(result) { + // Record the segment as done as soon as it is finished, not when the + // next one is started. Closing the tab after finishing a segment + // otherwise lost it, and returning to a set of notes at the segment + // you had already typed is exactly the thing this is meant to avoid. + if (session.type === 'custom' && customText?.docId) { + setPosition(customText.docId, Math.min(session.customIndex + 1, customText.chunks.length)); + } teardown(); setScreen('end'); cleanupScreen = renderEndScreen(root, { diff --git a/web/src/customLibrary.js b/web/src/customLibrary.js new file mode 100644 index 0000000..e97ae7c --- /dev/null +++ b/web/src/customLibrary.js @@ -0,0 +1,86 @@ +const STORAGE_KEY = 'typerpunk:library'; +// A document is stored whole so it can be re-chunked later - switching +// markdown between stripped and verbatim needs the original, not the chunks. +// Ten is enough for the notes someone is actually working through, and keeps +// localStorage well clear of its quota. +const MAX_DOCUMENTS = 10; +const MAX_RAW_CHARS = 400_000; + +// Imported documents, kept between visits. +// +// Custom text used to live only in memory: import a set of notes, reload the +// page, and it was gone. That is fine for pasting a paragraph to race, and +// useless for working through a file over several sittings - which is what +// people actually do with their own notes. +// +// Everything here stays on the device. Notes are not uploaded anywhere. + +function load() { + try { + const raw = JSON.parse(localStorage.getItem(STORAGE_KEY)); + return Array.isArray(raw) ? raw : []; + } catch { + return []; + } +} + +function save(docs) { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(docs)); + return true; + } catch { + // Quota, or storage disabled. The document still works for this + // session; it simply will not be there next time. + return false; + } +} + +export function listDocuments() { + return load().sort((a, b) => (b.lastOpened || 0) - (a.lastOpened || 0)); +} + +export function getDocument(id) { + return load().find(d => d.id === id) || null; +} + +/// Stores a document, replacing any earlier import of the same name so +/// re-importing an edited file updates it in place rather than accumulating +/// copies. Returns the stored record. +export function saveDocument({ name, raw, chunkCount }) { + const docs = load().filter(d => d.name !== name); + const existing = load().find(d => d.name === name); + const doc = { + id: existing?.id || `doc-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + name, + raw: raw.slice(0, MAX_RAW_CHARS), + chunkCount, + // Re-importing an edited file keeps your place only if it still makes + // sense: past the end of the new version, start again. + position: Math.min(existing?.position || 0, Math.max(0, chunkCount - 1)), + addedAt: existing?.addedAt || Date.now(), + lastOpened: Date.now(), + }; + docs.unshift(doc); + save(docs.slice(0, MAX_DOCUMENTS)); + return doc; +} + +/// Records how far through a document you have typed. +export function setPosition(id, position) { + const docs = load(); + const doc = docs.find(d => d.id === id); + if (!doc) return; + doc.position = Math.max(0, position); + doc.lastOpened = Date.now(); + save(docs); +} + +export function removeDocument(id) { + save(load().filter(d => d.id !== id)); +} + +/// How far through a document you are, as a percentage. +export function progressOf(doc) { + if (!doc || !doc.chunkCount) return 0; + return Math.min(100, Math.round((doc.position / doc.chunkCount) * 100)); +} diff --git a/web/src/customText.js b/web/src/customText.js index dc74541..ca8c98b 100644 --- a/web/src/customText.js +++ b/web/src/customText.js @@ -65,6 +65,12 @@ function splitLong(str, maxLen = 400) { return parts; } +/// Collapses the whitespace inside a chunk. The typing input is a single +/// line, so a chunk containing a newline can never be finished. +function flattenWhitespace(text) { + return text.replace(/\s+/g, ' ').trim(); +} + export function chunkPlainText(raw) { const normalized = raw.replace(/\r\n/g, '\n').trim(); if (!normalized) return []; @@ -73,7 +79,7 @@ export function chunkPlainText(raw) { const chunks = []; for (const block of source) { for (const piece of splitLong(block)) { - const trimmed = piece.trim(); + const trimmed = flattenWhitespace(piece); if (trimmed) chunks.push({ content: trimmed, time: null }); } } @@ -145,11 +151,86 @@ function parseLrc(raw) { return chunks; } -export function parseCustomContent(raw, filename) { + +// Markdown notes are the most common thing someone brings to a typing app to +// study: lecture notes, a cheatsheet, a page of documentation. Typed +// verbatim, most of what you retype is punctuation - hashes, asterisks, +// backticks and link brackets - rather than the material itself. +// +// `strip` removes the decoration and keeps the prose, which is the mode for +// studying what the notes say. Left off, the file is typed exactly as +// written, which is the mode for learning the syntax. +export function chunkMarkdown(raw, { strip = true } = {}) { + const normalized = raw.replace(/\r\n/g, '\n').trim(); + if (!normalized) return []; + + // Fenced code blocks are extracted whole and never stripped: their + // punctuation is the point, and paragraph splitting would cut them apart. + const segments = []; + const fence = /```[^\n]*\n([\s\S]*?)```/g; + let last = 0; + let m; + while ((m = fence.exec(normalized)) !== null) { + if (m.index > last) segments.push({ text: normalized.slice(last, m.index), code: false }); + segments.push({ text: m[1], code: true }); + last = m.index + m[0].length; + } + if (last < normalized.length) segments.push({ text: normalized.slice(last), code: false }); + + const chunks = []; + for (const seg of segments) { + if (seg.code) { + // One line at a time: a code block is typed the way it is written. + for (const line of seg.text.split('\n')) { + const t = line.trim(); + if (t) chunks.push({ content: t, time: null, code: true }); + } + continue; + } + for (const block of seg.text.split(/\n\s*\n/)) { + let text = block.trim(); + if (!text) continue; + if (strip) { + text = text + .replace(/^\s{0,3}#{1,6}\s+/gm, '') // heading markers + .replace(/^\s{0,3}>\s?/gm, '') // block quotes + .replace(/^\s*[-*+]\s+/gm, '') // bullet markers + .replace(/^\s*\d+[.)]\s+/gm, '') // ordered list markers + .replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1') // images -> alt text + .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1') // links -> label + .replace(/`([^`]+)`/g, '$1') // inline code + .replace(/(\*\*|__)(.*?)\1/g, '$2') // bold + .replace(/(\*|_)(.*?)\1/g, '$2') // italics + .replace(/^\s*([-*_]\s*){3,}$/gm, '') // horizontal rules + .replace(/\|/g, ' ') // table pipes + .replace(/[ \t]+/g, ' ') + .trim(); + } + // A heading on its own becomes a one-word chunk that is not worth + // typing; fold it into nothing and let the paragraph follow. + if (!text || text.length < 3) continue; + for (const piece of splitLong(text)) { + const t = flattenWhitespace(piece); + if (t) chunks.push({ content: t, time: null }); + } + } + } + return chunks; +} + +export function parseCustomContent(raw, filename, options = {}) { const ext = (filename || '').split('.').pop().toLowerCase(); if (ext === 'srt') return { chunks: parseSrt(raw), language: null, timed: true }; if (ext === 'vtt') return { chunks: parseVtt(raw), language: null, timed: true }; if (ext === 'lrc') return { chunks: parseLrc(raw), language: null, timed: true }; const language = languageForFilename(filename); + if (ext === 'md' || ext === 'markdown') { + return { + chunks: chunkMarkdown(raw, { strip: options.stripMarkdown !== false }), + language: null, + timed: false, + markdown: true, + }; + } return { chunks: chunkPlainText(raw), language, timed: false }; } diff --git a/web/src/screens/contributeScreen.js b/web/src/screens/contributeScreen.js new file mode 100644 index 0000000..4464ae2 --- /dev/null +++ b/web/src/screens/contributeScreen.js @@ -0,0 +1,162 @@ +import { escapeHtml } from '../util.js'; +import { renderCornerRail } from '../cornerRail.js'; +import { attachTooltips } from '../tooltip.js'; +import { renderTopRail } from '../topRail.js'; +import { api, ApiError } from '../api.js'; +import { getUser } from '../auth.js'; +import { CLOSE_ICON } from './icons.js'; + +// Submitting a passage, and - for moderators - reviewing what others have +// submitted. A bundled dataset stops growing at whatever fits in the +// repository; this is how a corpus gets past that. +const CATEGORIES = [ + 'quotes', 'literature', 'movies', 'anime', 'science', 'history', + 'technology', 'nature', 'philosophy', 'business', 'general', + 'programming', 'shell', 'sysadmin', 'hacking', +]; + +// Mirrors the server's own limits, so the count turns red before a submission +// is rejected rather than after. +const MIN_CONTENT = 40; +const MAX_CONTENT = 600; + +export function renderContributeScreen(root, { onBack, onShowStats, onShowPlaceholder, onShowAccount, onShowLeaderboard, onShowFriends, onShowMultiplayer, onShowStore }) { + let mine = []; + let queue = []; + let isModerator = false; + let cleanupInner = null; + + function statusLabel(s) { + if (s === 'approved') return 'Approved'; + if (s === 'rejected') return 'Not accepted'; + return 'Awaiting review'; + } + + async function load() { + if (!getUser()) return; + try { mine = await api.get('/api/texts/mine'); } catch { mine = []; } + // A 401 here simply means "not a moderator", which is the ordinary + // case rather than an error worth showing. + try { queue = await api.get('/api/texts/queue'); isModerator = true; } + catch { queue = []; isModerator = false; } + } + + function submissionRow(s, moderating) { + return ` +