diff options
| author | srdusr <[email protected]> | 2025-12-14 09:24:00 +0200 |
|---|---|---|
| committer | srdusr <[email protected]> | 2025-12-14 09:24:00 +0200 |
| commit | 3dbebfbc9345d2603908f32c0dabebc0ff21feb3 (patch) | |
| tree | 61a4f60187046a087424845eb8e22239ccd7d6ee /web/src/customLibrary.js | |
| parent | 8fc4440150ce84c9cc3f5ec13d50beb58a5b65bc (diff) | |
| download | typerpunk-3dbebfbc9345d2603908f32c0dabebc0ff21feb3.tar.gz typerpunk-3dbebfbc9345d2603908f32c0dabebc0ff21feb3.zip | |
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.
Diffstat (limited to 'web/src/customLibrary.js')
| -rw-r--r-- | web/src/customLibrary.js | 86 |
1 files changed, 86 insertions, 0 deletions
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)); +} |