srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/web/src/customLibrary.js
diff options
context:
space:
mode:
authorsrdusr <[email protected]>2025-12-14 09:24:00 +0200
committersrdusr <[email protected]>2025-12-14 09:24:00 +0200
commit3dbebfbc9345d2603908f32c0dabebc0ff21feb3 (patch)
tree61a4f60187046a087424845eb8e22239ccd7d6ee /web/src/customLibrary.js
parent8fc4440150ce84c9cc3f5ec13d50beb58a5b65bc (diff)
downloadtyperpunk-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.js86
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));
+}