diff --git a/.gitignore b/.gitignore index 784ff8a..5f2112b 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,6 @@ datascape *.exe bin/ companion/datascape-companion-* + +# Editor build tooling deps (the built bundle is committed; node_modules is not) +editor-build/node_modules/ diff --git a/CLAUDE.md b/CLAUDE.md index 4cb52ed..b36e8d5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,12 +16,30 @@ go build . make deploy ``` +### Editor bundle (the one build-pipeline exception) + +The page editor uses CodeMirror 6, vendored as a single pre-built IIFE at +`assets/editor/vendor/codemirror.bundle.js` and embedded via `embed.FS`. This is +the **only** deliberate exception to the "no build pipeline" rule below — it is a +one-time, committed artifact, not a runtime build. `go build` / `make deploy` +never touch Node and only consume the committed bundle. + +Regenerate the bundle **only** when upgrading the `@codemirror/*` versions: + +```bash +# bump versions in editor-build/package.json first, then: +make editor # runs `npm ci && npm run build` in editor-build/, rewrites the vendored bundle +``` + +Commit the regenerated `codemirror.bundle.js` and the updated +`editor-build/package-lock.json`. `editor-build/node_modules/` is gitignored. + ## HTTP API Surface | Method | Path | Behaviour | |--------|------|-----------| | GET | `/{path}/` | If folder exists: render `index.md` + list contents. If not: show empty create prompt. | -| GET | `/{path}/?edit` | Mobile-friendly editor with `index.md` content in a textarea | +| GET | `/{path}/?edit` | CodeMirror 6 editor initialized with `index.md` content | | POST | `/{path}` | Write `index.md` to disk; creates the folder if it does not exist yet | Non-existent paths without a trailing slash redirect to the slash form (GET only — POSTs @@ -49,7 +67,7 @@ Prefer separate, human-readable `.html` files over inlined HTML strings in Go. E ## Frontend Rules -- Vanilla JS only — no frameworks, no build pipeline +- Vanilla JS only — no frameworks, no build pipeline (the single exception is the vendored CodeMirror editor bundle; see Build & Deploy) - Each feature gets its own JS file; global behaviour goes in `global-shortcuts.js` - Do not inline JS in templates or merge unrelated features into one file - `ALT+SHIFT` is the modifier for all keyboard shortcuts — do not introduce others diff --git a/Makefile b/Makefile index 4dc8d8a..94bcd75 100644 --- a/Makefile +++ b/Makefile @@ -4,7 +4,10 @@ COMPANION_WIN := companion/datascape-companion-windows-amd64.exe COMPANION_LIN := companion/datascape-companion-linux-amd64 COMPANION_SRCS := $(wildcard cmd/companion/*.go) $(wildcard cmd/companion/*.html) go.mod go.sum -.PHONY: deploy companion companion-windows companion-linux companion-release +EDITOR_BUNDLE := assets/editor/vendor/codemirror.bundle.js +EDITOR_SRCS := $(wildcard editor-build/*.js) editor-build/package.json editor-build/package-lock.json + +.PHONY: deploy companion companion-windows companion-linux companion-release editor # Cross-compiled companion artifacts the wiki binary embeds. Both must exist # before `go build .` so embed.FS picks them up. @@ -24,6 +27,15 @@ companion: mkdir -p bin go build -o bin/ ./cmd/companion +# Regenerate the vendored CodeMirror bundle. One-time/dev-only step: run after +# upgrading the @codemirror/* versions in editor-build/package.json. The built +# artifact ($(EDITOR_BUNDLE)) is committed; `go build` only consumes it and +# never runs Node. +editor: $(EDITOR_BUNDLE) + +$(EDITOR_BUNDLE): $(EDITOR_SRCS) + cd editor-build && npm ci && npm run build + deploy: companion-release GOOS=linux GOARCH=arm GOARM=7 go build -o datascape-arm . ssh $(NAS) 'kill $$(cat /share/homes/luxick/.local/bin/datascape.pid) 2>/dev/null; rm -f /share/homes/luxick/.local/bin/datascape.pid' diff --git a/assets/editor/lists.js b/assets/editor/lists.js deleted file mode 100644 index 805932e..0000000 --- a/assets/editor/lists.js +++ /dev/null @@ -1,86 +0,0 @@ -window.EditorLists = (function () { - - function detectListPrefix(lineText) { - var m; - m = lineText.match(/^(\s*)(- \[[ x]\] )/); - if (m) return { indent: m[1], prefix: m[2], type: 'task' }; - m = lineText.match(/^(\s*)([-*+] )/); - if (m) return { indent: m[1], prefix: m[2], type: 'unordered' }; - m = lineText.match(/^(\s*)(\d+)\. /); - if (m) return { indent: m[1], prefix: m[2] + '. ', type: 'ordered', num: parseInt(m[2], 10) }; - m = lineText.match(/^(\s*)(> )/); - if (m) return { indent: m[1], prefix: m[2], type: 'blockquote' }; - return null; - } - - function continuationPrefix(info) { - if (info.type === 'task') return info.indent + '- [ ] '; - if (info.type === 'ordered') return info.indent + (info.num + 1) + '. '; - return info.indent + info.prefix; - } - - function renumberOrderedList(text, fromLineIndex, indent, startNum) { - var lines = text.split('\n'); - var re = new RegExp('^' + indent.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '(\\d+)\\. '); - var num = startNum !== undefined ? startNum : null; - for (var i = fromLineIndex; i < lines.length; i++) { - var m = lines[i].match(re); - if (!m) break; - if (num === null) num = parseInt(m[1], 10); - var newNumStr = String(num); - if (m[1] !== newNumStr) { - lines[i] = indent + newNumStr + '. ' + lines[i].slice(m[0].length); - } - num++; - } - return lines.join('\n'); - } - - function handleEnterKey(text, cursorPos) { - var before = text.slice(0, cursorPos); - var after = text.slice(cursorPos); - var lineStart = before.lastIndexOf('\n') + 1; - var lineEnd = text.indexOf('\n', cursorPos); - if (lineEnd === -1) lineEnd = text.length; - var fullLine = text.slice(lineStart, lineEnd); - var info = detectListPrefix(fullLine); - if (!info) return null; - - var contentAfterPrefix = fullLine.slice(info.indent.length + info.prefix.length); - if (contentAfterPrefix.trim() === '') { - var newText = text.slice(0, lineStart) + '\n' + after; - var newCursor = lineStart + 1; - if (info.type === 'ordered') { - var lineIndex = text.slice(0, lineStart).split('\n').length; - newText = renumberOrderedList(newText, lineIndex, info.indent); - } - return { text: newText, cursor: newCursor }; - } - - var cont = continuationPrefix(info); - var newText = before + '\n' + cont + after; - var newCursor = cursorPos + 1 + cont.length; - if (info.type === 'ordered') { - var insertedLineIndex = before.split('\n').length; - newText = renumberOrderedList(newText, insertedLineIndex, info.indent); - } - return { text: newText, cursor: newCursor }; - } - - function deleteOrderedLine(text, cursorPos) { - var lineStart = text.lastIndexOf('\n', cursorPos - 1) + 1; - var lineEnd = text.indexOf('\n', cursorPos); - if (lineEnd === -1) lineEnd = text.length; - var fullLine = text.slice(lineStart, lineEnd); - var info = detectListPrefix(fullLine); - if (!info || info.type !== 'ordered') return null; - - var newText = text.slice(0, lineStart) + text.slice(lineEnd === text.length ? lineEnd : lineEnd + 1); - var newCursor = lineStart; - var fromLineIndex = text.slice(0, lineStart).split('\n').length - 1; - newText = renumberOrderedList(newText, fromLineIndex, info.indent, info.num); - return { text: newText, cursor: Math.min(newCursor, newText.length) }; - } - - return { handleEnterKey: handleEnterKey, deleteOrderedLine: deleteOrderedLine }; -})(); diff --git a/assets/editor/main.html b/assets/editor/main.html index 3bca954..6838ffc 100644 --- a/assets/editor/main.html +++ b/assets/editor/main.html @@ -71,11 +71,13 @@ - +
+ - + + {{end}} diff --git a/assets/editor/main.js b/assets/editor/main.js index dfcfede..d6bc7e8 100644 --- a/assets/editor/main.js +++ b/assets/editor/main.js @@ -1,145 +1,147 @@ (function () { - var textarea = document.getElementById('editor'); - if (!textarea) return; + var mount = document.getElementById('editor'); + var hidden = document.getElementById('editor-content'); + if (!mount || !hidden || !window.CM) return; - var form = textarea.closest('form'); - - // --- DOM helpers --- - - // Route every edit through execCommand so the browser's native undo/redo - // stack is preserved. Direct assignment to textarea.value would wipe it. - function replaceRange(start, end, text) { - textarea.focus(); - textarea.selectionStart = start; - textarea.selectionEnd = end; - document.execCommand('insertText', false, text); - } - - function wrap(before, after, placeholder) { - var start = textarea.selectionStart; - var end = textarea.selectionEnd; - var hadSelection = end > start; - var selected = hadSelection ? textarea.value.slice(start, end) : placeholder; - replaceRange(start, end, before + selected + after); - if (!hadSelection) { - textarea.selectionStart = start + before.length; - textarea.selectionEnd = start + before.length + placeholder.length; - } - } - - function linePrefix(prefix) { - var start = textarea.selectionStart; - var lineStart = textarea.value.lastIndexOf('\n', start - 1) + 1; - replaceRange(lineStart, lineStart, prefix); - textarea.selectionStart = textarea.selectionEnd = start + prefix.length; - } - - function insertAtCursor(s) { - replaceRange(textarea.selectionStart, textarea.selectionEnd, s); - } - - function applyResult(result) { - var oldText = textarea.value; - var newText = result.text; - var prefixLen = 0; - var maxPrefix = Math.min(oldText.length, newText.length); - while (prefixLen < maxPrefix && oldText.charCodeAt(prefixLen) === newText.charCodeAt(prefixLen)) { - prefixLen++; - } - var oldEnd = oldText.length; - var newEnd = newText.length; - while (oldEnd > prefixLen && newEnd > prefixLen - && oldText.charCodeAt(oldEnd - 1) === newText.charCodeAt(newEnd - 1)) { - oldEnd--; - newEnd--; - } - replaceRange(prefixLen, oldEnd, newText.slice(prefixLen, newEnd)); - textarea.selectionStart = textarea.selectionEnd = result.cursor; - } - - function applyTableOp(fn, arg) { - var result = arg !== undefined - ? fn(textarea.value, textarea.selectionStart, arg) - : fn(textarea.value, textarea.selectionStart); - if (result) applyResult(result); - } - - // isValidWikiTarget mirrors the Go validator in wikilinks.go — absolute - // path, no empty/dot segments. Used to gate the INSERT confirm button. - function isValidWikiTarget(p) { - if (!p || p[0] !== '/') return false; - var trimmed = p.replace(/^\/+|\/+$/g, ''); - if (trimmed === '') return true; - var segs = trimmed.split('/'); - for (var i = 0; i < segs.length; i++) { - if (segs[i] === '' || segs[i] === '.' || segs[i] === '..') return false; - } - return true; - } - - function insertWikilink() { - var sel = textarea.value.slice(textarea.selectionStart, textarea.selectionEnd); - - var container = document.createElement('div'); - - var targetWrap = document.createElement('div'); - var targetInput = document.createElement('input'); - targetInput.type = 'text'; - targetInput.className = 'input'; - targetInput.placeholder = 'Page path or search…'; - targetWrap.appendChild(targetInput); - - var displayInput = document.createElement('input'); - displayInput.type = 'text'; - displayInput.className = 'input'; - displayInput.placeholder = 'Display text (optional)'; - if (sel) displayInput.value = sel; - - container.appendChild(targetWrap); - container.appendChild(displayInput); - - var handle = openModal({ - title: 'Insert link', - body: container, - confirm: { - label: 'INSERT', - initiallyDisabled: true, - onConfirm: function () { - var target = targetInput.value.trim(); - if (!isValidWikiTarget(target)) return; - var display = displayInput.value.trim(); - handle.close(); - insertAtCursor(display ? '[[' + target + '::' + display + ']]' : '[[' + target + ']]'); - } - } - }); - - function updateConfirm() { - handle.setConfirmDisabled(!isValidWikiTarget(targetInput.value.trim())); - } - targetInput.addEventListener('input', updateConfirm); - - window.attachSuggestions(targetInput, { - showFooter: false, - container: targetWrap, - onPick: function (r) { - targetInput.value = '/' + r.path; - updateConfirm(); - displayInput.focus(); - displayInput.select(); - } - }); - } - - // --- Actions --- + var form = hidden.closest('form'); var T = EditorTables; - var L = EditorLists; var D = EditorDates; var M = EditorMovie; + // --- CodeMirror setup --- + + // Shift+Enter (new table row below) / Shift+Delete (delete table row) run at + // highest precedence so they win over CM's default newline/forward-delete. + // Returning false (no table at cursor) lets CM fall back to its default. + function tableKey(fn) { + return function (view) { + var result = fn(view.state.doc.toString(), view.state.selection.main.head); + if (!result) return false; + dispatchFullReplace(result); + return true; + }; + } + + var tableKeymap = [ + { key: 'Shift-Enter', run: tableKey(T.insertRowBelow) }, + { key: 'Shift-Delete', run: tableKey(T.deleteRow) }, + ]; + + var state = CM.EditorState.create({ + doc: hidden.value, + extensions: [ + CM.history(), + CM.drawSelection(), + CM.indentOnInput(), + CM.EditorView.lineWrapping, + CM.markdown({ base: CM.markdownLanguage }), + CM.syntaxHighlighting(CM.highlightStyle), + CM.closeBrackets(), + CM.autocompletion({ override: [WikiComplete.source] }), + CM.theme, + CM.Prec.highest(CM.keymap.of(tableKeymap)), + CM.keymap.of([].concat( + CM.closeBracketsKeymap, + CM.completionKeymap, + CM.markdownKeymap, + CM.defaultKeymap, + CM.historyKeymap, + [CM.indentWithTab] + )), + ], + }); + + var view = new CM.EditorView({ state: state, parent: mount }); + view.focus(); + + // --- CM document helpers --- + + function dispatchFullReplace(result) { + view.dispatch({ + changes: { from: 0, to: view.state.doc.length, insert: result.text }, + selection: { anchor: result.cursor }, + scrollIntoView: true, + }); + view.focus(); + } + + function insertAtCursor(s) { + var sel = view.state.selection.main; + view.dispatch({ + changes: { from: sel.from, to: sel.to, insert: s }, + selection: { anchor: sel.from + s.length }, + scrollIntoView: true, + }); + view.focus(); + } + + function wrap(before, after, placeholder) { + var sel = view.state.selection.main; + var hadSelection = sel.to > sel.from; + var selected = hadSelection ? view.state.sliceDoc(sel.from, sel.to) : placeholder; + var insert = before + selected + after; + var anchor, head; + if (hadSelection) { + anchor = head = sel.from + insert.length; + } else { + anchor = sel.from + before.length; + head = anchor + placeholder.length; + } + view.dispatch({ + changes: { from: sel.from, to: sel.to, insert: insert }, + selection: { anchor: anchor, head: head }, + scrollIntoView: true, + }); + view.focus(); + } + + function linePrefix(prefix) { + var sel = view.state.selection.main; + var line = view.state.doc.lineAt(sel.from); + view.dispatch({ + changes: { from: line.from, to: line.from, insert: prefix }, + selection: { anchor: sel.from + prefix.length }, + scrollIntoView: true, + }); + view.focus(); + } + + function applyTableOp(fn, arg) { + var text = view.state.doc.toString(); + var pos = view.state.selection.main.head; + var result = arg !== undefined ? fn(text, pos, arg) : fn(text, pos); + if (result) dispatchFullReplace(result); + } + + // Adapter passed to movie.js so it reads/writes the CM document instead of a + // textarea (replace() dispatches a transaction; cursor lands after the block). + var movieCtx = { + getValue: function () { return view.state.doc.toString(); }, + replace: function (start, end, text) { + view.dispatch({ + changes: { from: start, to: end, insert: text }, + selection: { anchor: start + text.length }, + scrollIntoView: true, + }); + view.focus(); + }, + }; + + // --- Content sync --- + + // Serialize the CM document into the hidden textarea on submit only — Save + // must not depend on CM focus or async state (decision 8). requestSubmit + // fires this listener even for the ALT+SHIFT+S path. + function syncContent() { + hidden.value = view.state.doc.toString(); + } + form.addEventListener('submit', syncContent); + + // --- Actions --- + var actions = { - save: function () { form.submit(); }, + save: function () { form.requestSubmit(); }, bold: function () { wrap('**', '**', 'bold text'); }, italic: function () { wrap('*', '*', 'italic text'); }, h1: function () { linePrefix('# '); }, @@ -164,7 +166,7 @@ tbldeleterow: function () { applyTableOp(T.deleteRow); }, dateiso: function () { insertAtCursor(D.isoDate()); }, datelong: function () { insertAtCursor(D.longDate()); }, - movie: function () { M.run(textarea); }, + movie: function () { M.run(movieCtx); }, wide: function () { var enabled = !document.body.classList.contains('editor-wide'); document.body.classList.toggle('editor-wide', enabled); @@ -172,6 +174,19 @@ }, }; + // Wiki link button: drop an empty [[]] at the cursor and open the `[[` + // completion popup so the same autocomplete flow handles target selection. + function insertWikilink() { + var sel = view.state.selection.main; + view.dispatch({ + changes: { from: sel.from, to: sel.to, insert: '[[]]' }, + selection: { anchor: sel.from + 2 }, + scrollIntoView: true, + }); + view.focus(); + CM.startCompletion(view); + } + // --- Keyboard shortcut registration --- var keyMap = {}; @@ -197,31 +212,6 @@ } }); - // --- Textarea key handling --- - - textarea.addEventListener('keydown', function (e) { - if (e.key === 'Delete' && e.shiftKey) { - var result = T.deleteRow(textarea.value, textarea.selectionStart) - || L.deleteOrderedLine(textarea.value, textarea.selectionStart); - if (!result) return; - e.preventDefault(); - applyResult(result); - return; - } - if (e.key === 'Enter' && e.shiftKey) { - var result = T.insertRowBelow(textarea.value, textarea.selectionStart); - if (!result) return; - e.preventDefault(); - applyResult(result); - return; - } - if (e.key !== 'Enter') return; - var result = L.handleEnterKey(textarea.value, textarea.selectionStart); - if (!result) return; - e.preventDefault(); - applyResult(result); - }); - // --- Dropdowns --- document.querySelectorAll('.dropdown-toggle').forEach(wireDropdown); diff --git a/assets/editor/movie.js b/assets/editor/movie.js index 8d103db..273af1e 100644 --- a/assets/editor/movie.js +++ b/assets/editor/movie.js @@ -48,26 +48,20 @@ window.EditorMovie = (function () { return out.join('\n'); } - function replaceRange(ta, start, end, text) { - ta.focus(); - ta.selectionStart = start; - ta.selectionEnd = end; - document.execCommand('insertText', false, text); - } - - function insertOrReplace(ta, markup) { - var t = ta.value || ''; + // ctx is the CM adapter from main.js: { getValue(), replace(start,end,text) }. + function insertOrReplace(ctx, markup) { + var t = ctx.getValue() || ''; var b = t.indexOf(BEGIN); var e = t.indexOf(END); if (b !== -1 && e !== -1 && e > b) { - replaceRange(ta, b, e + END.length, markup); + ctx.replace(b, e + END.length, markup); } else { var h = t.match(/^#{1,6}\s+.+?\s*$/m); if (h) { var idx = t.indexOf(h[0]) + h[0].length; - replaceRange(ta, idx, idx, '\n\n' + markup); + ctx.replace(idx, idx, '\n\n' + markup); } else { - replaceRange(ta, 0, 0, t ? markup + '\n\n' : markup); + ctx.replace(0, 0, t ? markup + '\n\n' : markup); } } } @@ -125,7 +119,7 @@ window.EditorMovie = (function () { }); } - function importWithKey(textarea, key, initialTitle) { + function importWithKey(ctx, key, initialTitle) { var input = document.createElement('input'); input.type = 'text'; input.className = 'input'; @@ -148,7 +142,7 @@ window.EditorMovie = (function () { data.Error === 'Invalid API key!') { localStorage.removeItem(STORAGE_KEY); promptForKey(true, function (newKey) { - importWithKey(textarea, newKey, raw); + importWithKey(ctx, newKey, raw); }); return; } @@ -157,7 +151,7 @@ window.EditorMovie = (function () { (data && data.Error) || 'Movie not found.'); return; } - insertOrReplace(textarea, buildBlock(data)); + insertOrReplace(ctx, buildBlock(data)); }) .catch(function () { showMessage('Import failed', 'OMDb lookup failed.'); @@ -167,16 +161,16 @@ window.EditorMovie = (function () { }); } - function run(textarea) { - var initialTitle = firstHeading(textarea.value || ''); + function run(ctx) { + var initialTitle = firstHeading(ctx.getValue() || ''); var key = localStorage.getItem(STORAGE_KEY); if (!key) { promptForKey(false, function (newKey) { - importWithKey(textarea, newKey, initialTitle); + importWithKey(ctx, newKey, initialTitle); }); return; } - importWithKey(textarea, key, initialTitle); + importWithKey(ctx, key, initialTitle); } return { run: run }; diff --git a/assets/editor/vendor/codemirror.bundle.js b/assets/editor/vendor/codemirror.bundle.js new file mode 100644 index 0000000..5d6a409 --- /dev/null +++ b/assets/editor/vendor/codemirror.bundle.js @@ -0,0 +1,27 @@ +(()=>{var ls=[],Ea=[];(()=>{let n="lc,34,7n,7,7b,19,,,,2,,2,,,20,b,1c,l,g,,2t,7,2,6,2,2,,4,z,,u,r,2j,b,1m,9,9,,o,4,,9,,3,,5,17,3,3b,f,,w,1j,,,,4,8,4,,3,7,a,2,t,,1m,,,,2,4,8,,9,,a,2,q,,2,2,1l,,4,2,4,2,2,3,3,,u,2,3,,b,2,1l,,4,5,,2,4,,k,2,m,6,,,1m,,,2,,4,8,,7,3,a,2,u,,1n,,,,c,,9,,14,,3,,1l,3,5,3,,4,7,2,b,2,t,,1m,,2,,2,,3,,5,2,7,2,b,2,s,2,1l,2,,,2,4,8,,9,,a,2,t,,20,,4,,2,3,,,8,,29,,2,7,c,8,2q,,2,9,b,6,22,2,r,,,,,,1j,e,,5,,2,5,b,,10,9,,2u,4,,6,,2,2,2,p,2,4,3,g,4,d,,2,2,6,,f,,jj,3,qa,3,t,3,t,2,u,2,1s,2,,7,8,,2,b,9,,19,3,3b,2,y,,3a,3,4,2,9,,6,3,63,2,2,,1m,,,7,,,,,2,8,6,a,2,,1c,h,1r,4,1c,7,,,5,,14,9,c,2,w,4,2,2,,3,1k,,,2,3,,,3,1m,8,2,2,48,3,,d,,7,4,,6,,3,2,5i,1m,,5,ek,,5f,x,2da,3,3x,,2o,w,fe,6,2x,2,n9w,4,,a,w,2,28,2,7k,,3,,4,,p,2,5,,47,2,q,i,d,,12,8,p,b,1a,3,1c,,2,4,2,2,13,,1v,6,2,2,2,2,c,,8,,1b,,1f,,,3,2,2,5,2,,,16,2,8,,6m,,2,,4,,fn4,,kh,g,g,g,a6,2,gt,,6a,,45,5,1ae,3,,2,5,4,14,3,4,,4l,2,fx,4,ar,2,49,b,4w,,1i,f,1k,3,1d,4,2,2,1x,3,10,5,,8,1q,,c,2,1g,9,a,4,2,,2n,3,2,,,2,6,,4g,,3,8,l,2,1l,2,,,,,m,,e,7,3,5,5f,8,2,3,,,n,,29,,2,6,,,2,,,2,,2,6j,,2,4,6,2,,2,r,2,2d,8,2,,,2,2y,,,,2,6,,,2t,3,2,4,,5,77,9,,2,6t,,a,2,,,4,,40,4,2,2,4,,w,a,14,6,2,4,8,,9,6,2,3,1a,d,,2,ba,7,,6,,,2a,m,2,7,,2,,2,3e,6,3,,,2,,7,,,20,2,3,,,,9n,2,f0b,5,1n,7,t4,,1r,4,29,,f5k,2,43q,,,3,4,5,8,8,2,7,u,4,44,3,1iz,1j,4,1e,8,,e,,m,5,,f,11s,7,,h,2,7,,2,,5,79,7,c5,4,15s,7,31,7,240,5,gx7k,2o,3k,6o".split(",").map(e=>e?parseInt(e,36):1);for(let e=0,t=0;e