diff --git a/README.md b/README.md index 027106c..0c0ae47 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ Categories match the Transform tab and the folders under `src/transformers/` (ea #### **Cipher** - **ADFGX Cipher** - WWI ADFGVX-style polybius + column transposition +- **N7AX Cipher** - ADFGX-style polybius with N7AX coordinates + column transposition - **Affine Cipher** - Affine substitution (ax + b mod 26) - **Atbash Cipher** - Reverse-alphabet substitution (A↔Z) - **Autokey Cipher** - Key stream mixed with plaintext (autokey) @@ -266,6 +267,14 @@ Tabs appear in **UI order** below. **OpenRouter** (optional or required per tool - **Transform chain**: Optionally run transforms on each piece. - **Wrapping**: Start/end templates; `{n}` iterator marker; single-line vs multiline copy. +### 🔤 **Spelling Alphabets** + +- **Custom ICAO-style alphabets**: One word per letter A–Z, like NATO/ICAO phonetic spelling. +- **OpenRouter (optional)**: Enter a category/theme and generate a full alphabet; edit any letter before saving. +- **Manual mode**: No API key required — fill in all 26 letters yourself. +- **Saved locally**: Alphabets persist in browser `localStorage` as JSON. +- **Transforms integration**: Each saved alphabet appears on the Transforms page under `custom_spelling`. + ### 💬 **Gibberish** - **Dictionary mode**: Seeded random gibberish over a configurable character set. @@ -296,7 +305,7 @@ models ### 🔑 **OpenRouter API Key Setup** -**AI Translation**, **PromptCraft**, and **Anti-Classifier** require an [OpenRouter](https://openrouter.ai/) API key. **Decoder**’s optional “translate to English” also uses OpenRouter when enabled. +**AI Translation**, **PromptCraft**, **Anti-Classifier**, and **Spelling Alphabets** (optional generate) require an [OpenRouter](https://openrouter.ai/) API key. **Decoder**’s optional “translate to English” also uses OpenRouter when enabled. The Spelling Alphabets tool works fully without a key if you enter letters manually. 1. Create an account at [openrouter.ai](https://openrouter.ai/) 2. Generate an API key (starts with `sk-or-...`) diff --git a/build/build-transforms.js b/build/build-transforms.js index 5166b45..9fb4bbe 100755 --- a/build/build-transforms.js +++ b/build/build-transforms.js @@ -95,14 +95,18 @@ for (const [name, filePath] of Object.entries(transforms)) { } // Extract the object definition (remove comments, import, and export statements) - const cleanContent = content + let cleanContent = content .replace(/^\/\/.*$/gm, '') // Remove single-line comments .replace(/import\s+.*?from\s+['"].*?['"]\s*;?\s*/g, '') // Remove import statements .replace(/export default\s*/g, '') // Remove export statement .trim(); - + output += `// ${name} (from ${filePath})\n`; - output += `transforms['${name}'] = ${cleanContent}\n\n`; + if (/^new BaseTransformer\s*\(/.test(cleanContent)) { + output += `transforms['${name}'] = ${cleanContent}\n\n`; + } else { + output += `transforms['${name}'] = ${cleanContent}\n\n`; + } console.log(`✅ Bundled: ${name} (category: ${category})`); } catch (error) { diff --git a/build/inject-tool-templates.js b/build/inject-tool-templates.js index d17796c..94056a3 100644 --- a/build/inject-tool-templates.js +++ b/build/inject-tool-templates.js @@ -21,7 +21,8 @@ const templateFiles = [ 'tokenizer.html', 'bijection.html', 'splitter.html', - 'gibberish.html' + 'gibberish.html', + 'spellingalphabet.html' ]; diff --git a/css/style.css b/css/style.css index 174c914..38b8001 100644 --- a/css/style.css +++ b/css/style.css @@ -49,6 +49,8 @@ --randomizer-color-rgb: 156, 39, 176; --case-color: #9575cd; /* Indigo for case transformations */ --case-color-rgb: 149, 117, 205; + --custom-spelling-color: #ff7043; /* Deep orange for user spelling alphabets */ + --custom-spelling-color-rgb: 255, 112, 67; /* Active state gradient end colors */ --encoding-active-end: #9575cd; @@ -62,6 +64,7 @@ --technical-active-end: #26c6da; --randomizer-active-end: #ab47bc; --case-active-end: #b39ddb; + --custom-spelling-active-end: #ff8a65; /* Spacing scale */ --spacing-xs: 4px; @@ -1509,6 +1512,10 @@ body.dark-theme .mobile-tool-dropdown { border-left-color: var(--case-color); } +.legend-item.transform-category-custom_spelling { + border-left-color: var(--custom-spelling-color); +} + .transform-section:focus-within { border-color: var(--accent-color); box-shadow: var(--focus-shadow); @@ -1675,6 +1682,10 @@ body.dark-theme .mobile-tool-dropdown { border-left-color: var(--case-color); } +.category-title.transform-category-custom_spelling { + border-left-color: var(--custom-spelling-color); +} + .category-title.transform-category-translate { border-left-color: var(--ancient-color); text-transform: none; @@ -2605,6 +2616,10 @@ button.endsequence-item:hover .endsequence-copy-affordance { color: rgba(var(--case-color-rgb), 0.9); } +.transform-category-custom_spelling .transform-preview { + color: rgba(var(--custom-spelling-color-rgb), 0.9); +} + .transform-button:hover .transform-preview { background-color: rgba(0, 0, 0, 0.18); opacity: 1; @@ -2740,6 +2755,15 @@ button.endsequence-item:hover .endsequence-copy-affordance { box-shadow: 0 3px 10px rgba(var(--case-color-rgb), 0.2); } +.transform-category-custom_spelling { + background: linear-gradient(to right, rgba(var(--custom-spelling-color-rgb), 0.05), var(--button-bg)); +} + +.transform-category-custom_spelling:hover { + background: linear-gradient(to right, rgba(var(--custom-spelling-color-rgb), 0.15), var(--button-hover-bg)); + box-shadow: 0 3px 10px rgba(var(--custom-spelling-color-rgb), 0.2); +} + .transform-button:before { content: ''; position: absolute; @@ -2834,6 +2858,11 @@ button.endsequence-item:hover .endsequence-copy-affordance { box-shadow: 0 3px 12px rgba(var(--case-color-rgb), 0.4); } +.transform-category-custom_spelling.active { + background: linear-gradient(to right, var(--custom-spelling-color), var(--custom-spelling-active-end)); + box-shadow: 0 3px 12px rgba(var(--custom-spelling-color-rgb), 0.4); +} + /* Add a subtle indicator for clickable buttons */ .transform-button:after { content: ''; @@ -5276,3 +5305,106 @@ html { margin-left: var(--spacing-md); } } + +/* Custom Spelling Alphabet tool */ +.spelling-alphabet-layout .sa-empty-state { + padding: var(--spacing-md); + border: 1px dashed var(--input-border); + border-radius: 8px; + color: var(--text-muted); +} + +.sa-saved-list { + display: grid; + gap: var(--spacing-md); +} + +.sa-saved-card { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-md); + justify-content: space-between; + align-items: flex-start; + padding: var(--spacing-md); + border: 1px solid var(--input-border); + border-radius: 8px; + background: var(--secondary-bg); +} + +.sa-saved-card h4 { + margin: 0 0 var(--spacing-sm); + color: var(--accent-color); +} + +.sa-saved-preview, +.sa-saved-card p { + margin: 0 0 var(--spacing-xs); + color: var(--text-muted); +} + +.sa-saved-card-actions { + display: flex; + gap: var(--spacing-sm); + flex-wrap: wrap; +} + +.sa-delete-btn { + color: #ef5350; +} + +.sa-meta-grid { + margin-bottom: var(--spacing-md); +} + +.sa-generate-row { + margin-bottom: var(--spacing-md); +} + +.sa-hint { + margin-top: var(--spacing-sm); + color: var(--text-muted); + font-size: 0.95rem; +} + +.sa-error { + margin-bottom: var(--spacing-md); + padding: var(--spacing-sm) var(--spacing-md); + border-radius: 6px; + border: 1px solid #ef5350; + background: rgba(239, 83, 80, 0.12); + color: #ffabab; +} + +.sa-progress { + margin-bottom: var(--spacing-md); + color: var(--text-muted); +} + +.sa-letter-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); + gap: var(--spacing-sm); +} + +.sa-letter-field { + display: flex; + flex-direction: column; + gap: 4px; +} + +.sa-letter-label { + font-weight: 700; + color: var(--custom-spelling-color); +} + +.sa-letter-field input { + text-transform: uppercase; +} + +.sa-live-preview { + margin-top: var(--spacing-md); + padding: var(--spacing-md); + border-radius: 8px; + background: rgba(var(--custom-spelling-color-rgb), 0.08); + border: 1px solid rgba(var(--custom-spelling-color-rgb), 0.25); +} diff --git a/index.template.html b/index.template.html index 004bbaa..ae65b4b 100644 --- a/index.template.html +++ b/index.template.html @@ -404,6 +404,8 @@ + + @@ -438,6 +440,7 @@ + diff --git a/js/core/customSpellingAlphabets.js b/js/core/customSpellingAlphabets.js new file mode 100644 index 0000000..d1f62d7 --- /dev/null +++ b/js/core/customSpellingAlphabets.js @@ -0,0 +1,139 @@ +/** + * Persist user-created spelling alphabets in localStorage and register them on window.transforms. + */ +(function(global) { + 'use strict'; + + var STORAGE_KEY = 'customSpellingAlphabets'; + var TRANSFORM_KEY_PREFIX = 'custom_spelling_'; + + function createId() { + if (global.crypto && typeof global.crypto.randomUUID === 'function') { + return global.crypto.randomUUID(); + } + return 'sa_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 10); + } + + function loadAll() { + try { + var raw = global.localStorage.getItem(STORAGE_KEY); + if (!raw) { + return []; + } + var parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) { + return []; + } + return parsed.filter(function(entry) { + return entry && entry.id && entry.name && entry.alphabet; + }); + } catch (e) { + console.warn('Failed to load custom spelling alphabets:', e); + return []; + } + } + + function saveAll(entries) { + global.localStorage.setItem(STORAGE_KEY, JSON.stringify(entries)); + } + + function transformKeyForId(id) { + return TRANSFORM_KEY_PREFIX + id.replace(/[^a-zA-Z0-9_-]/g, '_'); + } + + function removeRegisteredCustomTransforms() { + if (!global.transforms) { + return; + } + Object.keys(global.transforms).forEach(function(key) { + if (key.indexOf(TRANSFORM_KEY_PREFIX) === 0) { + delete global.transforms[key]; + } + }); + } + + function registerCustomTransforms() { + if (!global.transforms) { + global.transforms = {}; + } + if (!global.SpellingAlphabetTransform) { + console.warn('SpellingAlphabetTransform module missing; custom alphabets not registered.'); + return; + } + + removeRegisteredCustomTransforms(); + + loadAll().forEach(function(entry) { + var key = transformKeyForId(entry.id); + global.transforms[key] = global.SpellingAlphabetTransform.create({ + id: entry.id, + name: entry.name, + category: 'custom_spelling', + alphabet: entry.alphabet, + priority: 150 + }); + }); + } + + function syncCustomSpellingAlphabets() { + registerCustomTransforms(); + } + + function saveMapping(mapping) { + var entries = loadAll(); + var now = Date.now(); + var normalizedAlphabet = global.SpellingAlphabetTransform + ? global.SpellingAlphabetTransform.normalizeAlphabet(mapping.alphabet) + : mapping.alphabet; + var payload = { + id: mapping.id || createId(), + name: String(mapping.name || '').trim(), + category: String(mapping.category || '').trim(), + alphabet: normalizedAlphabet, + createdAt: mapping.createdAt || now, + updatedAt: now + }; + + var index = entries.findIndex(function(entry) { + return entry.id === payload.id; + }); + if (index >= 0) { + payload.createdAt = entries[index].createdAt || payload.createdAt; + entries[index] = payload; + } else { + entries.push(payload); + } + + saveAll(entries); + syncCustomSpellingAlphabets(); + return payload; + } + + function deleteMapping(id) { + var entries = loadAll().filter(function(entry) { + return entry.id !== id; + }); + saveAll(entries); + syncCustomSpellingAlphabets(); + } + + function getById(id) { + return loadAll().find(function(entry) { + return entry.id === id; + }) || null; + } + + global.CustomSpellingAlphabets = { + STORAGE_KEY: STORAGE_KEY, + loadAll: loadAll, + saveAll: saveAll, + saveMapping: saveMapping, + deleteMapping: deleteMapping, + getById: getById, + syncCustomSpellingAlphabets: syncCustomSpellingAlphabets + }; + + global.syncCustomSpellingAlphabets = syncCustomSpellingAlphabets; + + syncCustomSpellingAlphabets(); +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/js/core/spellingAlphabetTransform.js b/js/core/spellingAlphabetTransform.js new file mode 100644 index 0000000..0c54a0e --- /dev/null +++ b/js/core/spellingAlphabetTransform.js @@ -0,0 +1,272 @@ +/** + * Factory for ICAO-style spelling alphabet transforms (built-in or user-saved). + */ +(function(global) { + 'use strict'; + + var LETTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split(''); + var SAMPLE_TEXT = 'hello world'; + + function emptyAlphabet() { + var alphabet = {}; + LETTERS.forEach(function(letter) { + alphabet[letter] = ''; + }); + return alphabet; + } + + function normalizeWord(word) { + return word ? String(word).toUpperCase().replace(/[^A-Z0-9]/g, '') : ''; + } + + function countFilled(alphabet) { + return LETTERS.filter(function(letter) { + return !!alphabet[letter]; + }).length; + } + + function mergeAlphabet(target, source) { + LETTERS.forEach(function(letter) { + var word = normalizeWord(source && source[letter]); + if (word && !target[letter]) { + target[letter] = word; + } + }); + return target; + } + + function tryParseJsonObject(text) { + var start = text.indexOf('{'); + var end = text.lastIndexOf('}'); + if (start === -1 || end <= start) { + return null; + } + + var candidates = [ + text.slice(start, end + 1), + text.slice(start, end + 1).replace(/,\s*([}\]])/g, '$1') + ]; + + for (var i = 0; i < candidates.length; i++) { + try { + var parsed = JSON.parse(candidates[i]); + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + return parsed; + } + } catch (e) { + // try next candidate + } + } + return null; + } + + function extractPairsFromText(text) { + var alphabet = emptyAlphabet(); + var patterns = [ + /"([A-Z])"\s*:\s*"([^"]+)"/g, + /'([A-Z])'\s*:\s*'([^']+)'/g, + /(?:^|[\n,{])\s*([A-Z])\s*[:=\-–]\s*"?([A-Za-z0-9]+)"?/g, + /(?:^|\n)\s*([A-Z])\s+[—–-]\s+([A-Za-z0-9]+)/g + ]; + + patterns.forEach(function(pattern) { + var match; + while ((match = pattern.exec(text)) !== null) { + var letter = match[1].toUpperCase(); + var word = normalizeWord(match[2]); + if (LETTERS.indexOf(letter) !== -1 && word && !alphabet[letter]) { + alphabet[letter] = word; + } + } + }); + + return alphabet; + } + + function parseAlphabetResponse(rawText) { + var text = String(rawText || '').trim(); + if (!text) { + throw new Error('Empty response from model.'); + } + + var fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/i); + if (fenced) { + text = fenced[1].trim(); + } + + var merged = emptyAlphabet(); + var jsonObject = tryParseJsonObject(text); + if (jsonObject) { + mergeAlphabet(merged, normalizeAlphabet(jsonObject)); + } + mergeAlphabet(merged, extractPairsFromText(text)); + + var filledCount = countFilled(merged); + if (filledCount === 0) { + throw new Error('Model did not return JSON. Try again or fill letters manually.'); + } + + return { + alphabet: merged, + filledCount: filledCount, + partial: filledCount < LETTERS.length + }; + } + + function extractMessageContent(message) { + if (!message) { + return ''; + } + var content = message.content; + if (typeof content === 'string') { + return content; + } + if (Array.isArray(content)) { + return content.map(function(part) { + if (typeof part === 'string') { + return part; + } + if (part && typeof part.text === 'string') { + return part.text; + } + return ''; + }).join('\n'); + } + return content == null ? '' : String(content); + } + + function buildAlphabetPrompts(category) { + var theme = String(category || '').trim() || 'general'; + + var system = [ + 'You create NATO/ICAO-style spelling alphabets.', + '', + 'Task: given a theme, produce one codeword per letter A–Z for spelling text aloud.', + '', + 'Hard rules:', + '- Output ONLY valid JSON — one object, no markdown, no code fences, no explanation.', + '- Exactly 26 keys: "A" through "Z" (uppercase).', + '- Each value is ONE uppercase English word with no spaces.', + '- Every word MUST start with its key letter (A→ANCHOR, not A→HARBOR).', + '- All 26 words must be unique.', + '- Words must fit the theme and be easy to say aloud (prefer 1–3 syllables).', + '- Avoid obscure jargon unless the theme requires it.', + '', + 'Letter tips:', + '- Q, X, and Z are hard: pick the best theme word that starts with that letter.', + '- For X, XRAY or a theme word starting with X is fine.', + '', + 'Example theme "nautical" (format only — use different words for other themes):', + '{"A":"ANCHOR","B":"BUOY","C":"CORAL","D":"DOCK","E":"EDDY","F":"FOG","G":"GALLEY","H":"HARBOR","I":"ISLAND","J":"JIB","K":"KNOT","L":"LAGOON","M":"MAST","N":"NAVY","O":"OCEAN","P":"PORT","Q":"QUAY","R":"REEF","S":"SAIL","T":"TIDE","U":"UNDERTOW","V":"VOYAGE","W":"WHARF","X":"XRAY","Y":"YACHT","Z":"ZEPHYR"}' + ].join('\n'); + + var user = [ + 'Theme: "' + theme + '"', + '', + 'Return the complete A–Z JSON object now.', + 'Double-check: 26 keys, each word starts with its letter, all unique, theme-appropriate.' + ].join('\n'); + + return { system: system, user: user }; + } + + function buildAlphabetPrompt(category) { + return buildAlphabetPrompts(category).user; + } + + function normalizeAlphabet(alphabet) { + var normalized = emptyAlphabet(); + if (!alphabet || typeof alphabet !== 'object') { + return normalized; + } + LETTERS.forEach(function(letter) { + var word = alphabet[letter] || alphabet[letter.toLowerCase()]; + normalized[letter] = normalizeWord(word); + }); + return normalized; + } + + function createSpellingAlphabetTransform(config) { + var alphabet = normalizeAlphabet(config.alphabet); + var name = config.name || 'Spelling Alphabet'; + var category = config.category || 'custom_spelling'; + var priority = typeof config.priority === 'number' ? config.priority : 200; + var customId = config.id || null; + + var transform = { + name: name, + priority: priority, + category: category, + alphabet: alphabet, + customSpellingId: customId, + func: function(text) { + var cleaned = text.toUpperCase().replace(/[^A-Z]/g, ''); + if (cleaned.length === 0) { + return text; + } + + var result = ''; + for (var i = 0; i < cleaned.length; i++) { + var char = cleaned[i]; + if (this.alphabet[char]) { + result += this.alphabet[char] + ' '; + } else { + result += char + ' '; + } + } + return result.trim(); + }, + reverse: function(text) { + var reverseMap = {}; + for (var letter in this.alphabet) { + if (Object.prototype.hasOwnProperty.call(this.alphabet, letter)) { + reverseMap[this.alphabet[letter].toUpperCase()] = letter; + } + } + + var words = text.toUpperCase().split(/\s+/); + var decoded = ''; + for (var j = 0; j < words.length; j++) { + var word = words[j]; + if (reverseMap[word]) { + decoded += reverseMap[word]; + } else if (word.length === 1 && /[A-Z]/.test(word)) { + decoded += word; + } + } + return decoded; + }, + preview: function(text) { + if (!text) { + return '[spelling]'; + } + return this.func(text.slice(0, 3)); + }, + detector: function(text) { + var words = Object.values(this.alphabet).filter(Boolean); + if (words.length < 2) { + return false; + } + var upper = text.toUpperCase(); + var matches = words.filter(function(word) { + return upper.includes(word); + }); + return matches.length >= 2; + } + }; + + return transform; + } + + global.SpellingAlphabetTransform = { + LETTERS: LETTERS, + SAMPLE_TEXT: SAMPLE_TEXT, + emptyAlphabet: emptyAlphabet, + normalizeAlphabet: normalizeAlphabet, + parseAlphabetResponse: parseAlphabetResponse, + extractMessageContent: extractMessageContent, + buildAlphabetPrompts: buildAlphabetPrompts, + buildAlphabetPrompt: buildAlphabetPrompt, + create: createSpellingAlphabetTransform + }; +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/js/core/toolRegistry.js b/js/core/toolRegistry.js index ac73eb7..901fa71 100644 --- a/js/core/toolRegistry.js +++ b/js/core/toolRegistry.js @@ -196,6 +196,9 @@ if (typeof MutationTool !== 'undefined') { if (typeof PromptCraftTool !== 'undefined') { window.toolRegistry.register(new PromptCraftTool()); } +if (typeof SpellingAlphabetTool !== 'undefined') { + window.toolRegistry.register(new SpellingAlphabetTool()); +} if (typeof SplitterTool !== 'undefined') { window.toolRegistry.register(new SplitterTool()); } diff --git a/js/data/openrouterModels.js b/js/data/openrouterModels.js index 794081b..7db5453 100644 --- a/js/data/openrouterModels.js +++ b/js/data/openrouterModels.js @@ -46,5 +46,6 @@ window.OPENROUTER_MODELS = [ { id: 'nousresearch/hermes-3-llama-3.1-405b', name: 'Hermes 3 405B', provider: 'Nous' }, { id: 'perplexity/sonar-deep-research', name: 'Sonar Deep Research', provider: 'Perplexity' }, { id: 'perplexity/sonar-pro', name: 'Sonar Pro', provider: 'Perplexity' }, + { id: 'openrouter/free', name: 'Free (auto-pick)', provider: 'OpenRouter' }, { id: 'openrouter/auto', name: 'Auto (best for price)', provider: 'OpenRouter' } ]; diff --git a/js/tools/SpellingAlphabetTool.js b/js/tools/SpellingAlphabetTool.js new file mode 100644 index 0000000..5cb7281 --- /dev/null +++ b/js/tools/SpellingAlphabetTool.js @@ -0,0 +1,331 @@ +/** + * Spelling Alphabet Tool — create custom ICAO-style alphabets (OpenRouter or manual). + */ +class SpellingAlphabetTool extends Tool { + constructor() { + super({ + id: 'spellingalphabet', + name: 'Spelling', + icon: 'fa-spell-check', + title: 'Custom spelling alphabets', + order: 8 + }); + } + + getVueData() { + var freeModels = [ + { id: 'openrouter/free', name: 'Free (auto-pick)', provider: 'OpenRouter' }, + { id: 'google/gemma-3-27b-it:free', name: 'Gemma 3 27B (free)', provider: 'Google' }, + { id: 'google/gemma-3-12b-it:free', name: 'Gemma 3 12B (free)', provider: 'Google' }, + { id: 'google/gemma-3-4b-it:free', name: 'Gemma 3 4B (free)', provider: 'Google' }, + { id: 'meta-llama/llama-3.3-70b-instruct:free', name: 'Llama 3.3 70B (free)', provider: 'Meta' } + ]; + var allModels = (typeof window !== 'undefined' && window.OPENROUTER_MODELS && window.OPENROUTER_MODELS.length) + ? window.OPENROUTER_MODELS + : [{ id: 'openrouter/auto', name: 'Auto (best for price)', provider: 'OpenRouter' }]; + var seen = {}; + var saModels = []; + freeModels.forEach(function(model) { + if (!seen[model.id]) { + seen[model.id] = true; + saModels.push(model); + } + }); + allModels.forEach(function(model) { + if (!seen[model.id]) { + seen[model.id] = true; + saModels.push(model); + } + }); + + return { + saView: 'list', + saAlphabets: [], + saEditingId: null, + saName: '', + saCategory: '', + saAlphabet: SpellingAlphabetTransform.emptyAlphabet(), + saLoading: false, + saError: '', + saModel: localStorage.getItem('sa-model') || 'openrouter/free', + saLetters: SpellingAlphabetTransform.LETTERS, + saModels: saModels + }; + } + + getVueMethods() { + return { + saGetApiKey: function() { + var key = localStorage.getItem('openrouter-api-key') || + localStorage.getItem('plinyos-api-key') || + localStorage.getItem('openrouter_api_key') || ''; + if (!key && this.openrouterApiKey) { + key = this.openrouterApiKey; + localStorage.setItem('openrouter-api-key', key.trim()); + } + return key.trim(); + }, + saHasApiKey: function() { + return !!this.saGetApiKey(); + }, + saLoadAlphabets: function() { + this.saAlphabets = CustomSpellingAlphabets.loadAll(); + }, + saStartNew: function() { + this.saView = 'edit'; + this.saEditingId = null; + this.saName = ''; + this.saCategory = ''; + this.saAlphabet = SpellingAlphabetTransform.emptyAlphabet(); + this.saError = ''; + }, + saEditAlphabet: function(entry) { + this.saView = 'edit'; + this.saEditingId = entry.id; + this.saName = entry.name; + this.saCategory = entry.category || ''; + this.saAlphabet = Object.assign( + SpellingAlphabetTransform.emptyAlphabet(), + SpellingAlphabetTransform.normalizeAlphabet(entry.alphabet) + ); + this.saError = ''; + }, + saCancelEdit: function() { + this.saView = 'list'; + this.saEditingId = null; + this.saError = ''; + }, + saDeleteAlphabet: function(entry) { + if (!entry || !entry.id) { + return; + } + if (!window.confirm('Delete "' + entry.name + '"? This removes it from the Transforms page too.')) { + return; + } + CustomSpellingAlphabets.deleteMapping(entry.id); + this.saLoadAlphabets(); + this.saRefreshTransforms(); + if (typeof this.showNotification === 'function') { + this.showNotification('Spelling alphabet deleted', 'success', 'fas fa-trash'); + } + }, + saSuggestName: function() { + var category = String(this.saCategory || '').trim(); + if (!category) { + return 'Custom Spelling Alphabet'; + } + return category.replace(/\b\w/g, function(c) { return c.toUpperCase(); }) + ' Spelling Alphabet'; + }, + saParseAlphabetJson: function(rawText) { + return SpellingAlphabetTransform.parseAlphabetResponse(rawText); + }, + saBuildGenerationRequest: function(category) { + var prompts = SpellingAlphabetTransform.buildAlphabetPrompts(category); + var body = { + model: this.saModel, + temperature: 0.2, + max_tokens: 1200, + messages: [ + { role: 'system', content: prompts.system }, + { role: 'user', content: prompts.user } + ] + }; + + if (this.saModel !== 'openrouter/free') { + body.response_format = { type: 'json_object' }; + } + + return body; + }, + saGenerateAlphabet: function() { + var category = String(this.saCategory || '').trim(); + if (!category) { + this.saError = 'Enter a category or theme first (e.g. nautical, cooking, astronomy).'; + return; + } + + var apiKey = this.saGetApiKey(); + if (!apiKey) { + this.saError = 'No OpenRouter API key. Add one in Advanced Settings, or fill in letters manually below.'; + return; + } + + this.saLoading = true; + this.saError = ''; + + var self = this; + var requestBody = this.saBuildGenerationRequest(category); + + fetch('https://openrouter.ai/api/v1/chat/completions', { + method: 'POST', + headers: { + 'Authorization': 'Bearer ' + apiKey, + 'Content-Type': 'application/json', + 'HTTP-Referer': window.location.origin, + 'X-Title': 'P4RS3LT0NGV3 Spelling Alphabet' + }, + body: JSON.stringify(requestBody) + }) + .then(function(response) { + if (response.status === 401) { + throw new Error('Invalid API key. Check your OpenRouter key in Advanced Settings.'); + } + if (response.status === 402) { + throw new Error('Insufficient credits on your OpenRouter account.'); + } + if (response.status === 400 && requestBody.response_format) { + delete requestBody.response_format; + return fetch('https://openrouter.ai/api/v1/chat/completions', { + method: 'POST', + headers: { + 'Authorization': 'Bearer ' + apiKey, + 'Content-Type': 'application/json', + 'HTTP-Referer': window.location.origin, + 'X-Title': 'P4RS3LT0NGV3 Spelling Alphabet' + }, + body: JSON.stringify(requestBody) + }); + } + if (!response.ok) { + throw new Error('OpenRouter request failed (HTTP ' + response.status + ').'); + } + return response; + }) + .then(function(response) { + if (!response.ok) { + throw new Error('OpenRouter request failed (HTTP ' + response.status + ').'); + } + return response.json(); + }) + .then(function(data) { + var message = data && + data.choices && + data.choices[0] && + data.choices[0].message; + var content = SpellingAlphabetTransform.extractMessageContent(message); + var parsed = self.saParseAlphabetJson(content); + self.saAlphabet = parsed.alphabet; + if (!self.saName.trim()) { + self.saName = self.saSuggestName(); + } + if (parsed.partial) { + self.saError = 'Parsed ' + parsed.filledCount + '/26 letters. Fill in the rest below.'; + } else { + self.saError = ''; + } + if (typeof self.showNotification === 'function') { + var note = parsed.partial + ? 'Partial alphabet generated — complete missing letters before saving' + : 'Alphabet generated — review and edit letters before saving'; + self.showNotification(note, parsed.partial ? 'warning' : 'success', 'fas fa-wand-magic-sparkles'); + } + }) + .catch(function(err) { + self.saError = err.message || 'Failed to generate alphabet.'; + }) + .finally(function() { + self.saLoading = false; + }); + }, + saValidateAlphabet: function() { + var name = String(this.saName || '').trim(); + if (!name) { + return 'Enter a name for this spelling alphabet.'; + } + + var missing = this.saLetters.filter(function(letter) { + return !String(this.saAlphabet[letter] || '').trim(); + }, this); + if (missing.length) { + return 'Fill in all 26 letters. Missing: ' + missing.join(', '); + } + + var seen = {}; + for (var i = 0; i < this.saLetters.length; i++) { + var letter = this.saLetters[i]; + var word = String(this.saAlphabet[letter] || '').toUpperCase(); + if (seen[word]) { + return 'Duplicate word "' + word + '" for letters ' + seen[word] + ' and ' + letter + '.'; + } + seen[word] = letter; + } + + return ''; + }, + saSaveAlphabet: function() { + var validationError = this.saValidateAlphabet(); + if (validationError) { + this.saError = validationError; + return; + } + + var saved = CustomSpellingAlphabets.saveMapping({ + id: this.saEditingId, + name: this.saName.trim(), + category: this.saCategory.trim(), + alphabet: this.saAlphabet + }); + + localStorage.setItem('sa-model', this.saModel); + this.saLoadAlphabets(); + this.saRefreshTransforms(); + this.saView = 'list'; + this.saEditingId = saved.id; + this.saError = ''; + + if (typeof this.showNotification === 'function') { + this.showNotification('Saved — find it on the Transforms page under custom_spelling', 'success', 'fas fa-check'); + } + }, + saRefreshTransforms: function() { + if (typeof this.refreshCustomSpellingTransforms === 'function') { + this.refreshCustomSpellingTransforms(); + } + }, + saPreviewSample: function() { + var sample = SpellingAlphabetTransform.SAMPLE_TEXT; + if (!window.SpellingAlphabetTransform) { + return ''; + } + var temp = SpellingAlphabetTransform.create({ + name: this.saName || 'Preview', + alphabet: this.saAlphabet + }); + return temp.func(sample); + }, + saSampleForEntry: function(entry) { + if (!entry || !window.SpellingAlphabetTransform) { + return ''; + } + var temp = SpellingAlphabetTransform.create({ + name: entry.name, + alphabet: entry.alphabet + }); + return temp.func(SpellingAlphabetTransform.SAMPLE_TEXT); + }, + saFilledLetterCount: function() { + return this.saLetters.filter(function(letter) { + return String(this.saAlphabet[letter] || '').trim().length > 0; + }, this).length; + } + }; + } + + getVueLifecycle() { + return { + mounted: function() { + this.saLoadAlphabets(); + } + }; + } + + onActivate(vueInstance) { + vueInstance.saLoadAlphabets(); + } +} + +if (typeof module !== 'undefined' && module.exports) { + module.exports = SpellingAlphabetTool; +} else { + window.SpellingAlphabetTool = SpellingAlphabetTool; +} diff --git a/js/tools/TransformTool.js b/js/tools/TransformTool.js index 990ff1f..cac2b0f 100644 --- a/js/tools/TransformTool.js +++ b/js/tools/TransformTool.js @@ -13,27 +13,7 @@ class TransformTool extends Tool { } getVueData() { - const transforms = (window.transforms && Object.keys(window.transforms).length > 0) - ? Object.entries(window.transforms) - .filter(([key, transform]) => { - // Filter out transforms that don't have required properties - if (!transform || !transform.name || !transform.func) { - console.warn(`Transform "${key}" is missing required properties (name or func)`, transform); - return false; - } - return true; - }) - .map(([key, transform]) => ({ - name: transform.name, - func: transform.func.bind(transform), - preview: transform.preview ? transform.preview.bind(transform) : function() { return '[preview]'; }, - reverse: transform.reverse ? transform.reverse.bind(transform) : null, - category: transform.category || 'special', - configurableOptions: transform.configurableOptions || [], - hasConfigurableOptions: Array.isArray(transform.configurableOptions) && transform.configurableOptions.length > 0, - inputKind: transform.inputKind === 'text' ? 'text' : 'textarea' - })) - : []; + const transforms = this.buildTransformsFromWindow(); const categorySet = new Set(); transforms.forEach(transform => { @@ -77,6 +57,55 @@ class TransformTool extends Tool { transformOptionsDraft: {} }; } + + buildTransformsFromWindow() { + if (typeof window !== 'undefined' && typeof window.syncCustomSpellingAlphabets === 'function') { + window.syncCustomSpellingAlphabets(); + } + + if (!window.transforms || Object.keys(window.transforms).length === 0) { + return []; + } + + return Object.entries(window.transforms) + .filter(([key, transform]) => { + if (!transform || !transform.name || !transform.func) { + console.warn(`Transform "${key}" is missing required properties (name or func)`, transform); + return false; + } + return true; + }) + .map(([key, transform]) => ({ + name: transform.name, + func: transform.func.bind(transform), + preview: transform.preview ? transform.preview.bind(transform) : function() { return '[preview]'; }, + reverse: transform.reverse ? transform.reverse.bind(transform) : null, + category: transform.category || 'special', + configurableOptions: transform.configurableOptions || [], + hasConfigurableOptions: Array.isArray(transform.configurableOptions) && transform.configurableOptions.length > 0, + inputKind: transform.inputKind === 'text' ? 'text' : 'textarea' + })); + } + + rebuildTransformCategories(transforms) { + const categorySet = new Set(); + transforms.forEach(transform => { + if (transform.category) { + categorySet.add(transform.category); + } + }); + + const allCategories = Array.from(categorySet); + const categoriesWithoutRandomizer = allCategories.filter(c => c !== 'randomizer'); + const legendCategories = [...categoriesWithoutRandomizer.sort((a, b) => a.localeCompare(b)), 'randomizer']; + + const savedOrder = this.loadCategoryOrder(); + const sectionCategories = savedOrder && savedOrder.length > 0 + ? this.mergeCategoryOrder(allCategories, savedOrder) + : [...legendCategories]; + + return { legendCategories, sectionCategories }; + } loadTransformOptionPrefs() { try { @@ -609,6 +638,23 @@ class TransformTool extends Tool { this.transformOutput = window.EmojiUtils.joinEmojis(transformedSegments); } }, + refreshCustomSpellingTransforms: function() { + const transformTool = window.toolRegistry && window.toolRegistry.get('transforms'); + if (!transformTool || typeof transformTool.buildTransformsFromWindow !== 'function') { + return; + } + + this.transforms = transformTool.buildTransformsFromWindow(); + const categories = transformTool.rebuildTransformCategories(this.transforms); + this.legendCategories = categories.legendCategories; + this.categories = categories.sectionCategories; + + this.$nextTick(() => { + if (typeof this.initializeCategoryNavigation === 'function') { + this.initializeCategoryNavigation(); + } + }); + }, initializeCategoryNavigation: function() { this.$nextTick(() => { const legendItems = document.querySelectorAll('.transform-category-legend .legend-item'); diff --git a/package-lock.json b/package-lock.json index 09c6854..27c2426 100644 --- a/package-lock.json +++ b/package-lock.json @@ -176,9 +176,9 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", "dev": true, "license": "MIT", "dependencies": { @@ -452,9 +452,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", "dev": true, "funding": [ { diff --git a/src/transformers/cipher/n7ax.js b/src/transformers/cipher/n7ax.js new file mode 100644 index 0000000..a877102 --- /dev/null +++ b/src/transformers/cipher/n7ax.js @@ -0,0 +1,185 @@ +// N7AX cipher — Polybius + columnar transposition (ADFGX-style with N7AX coordinates) +import BaseTransformer from '../BaseTransformer.js'; + +export default new BaseTransformer({ + name: 'N7AX Cipher', + priority: 60, + category: 'cipher', + key: 'N7AX', + configurableOptions: [ + { + id: 'key', + label: 'Transposition keyword', + type: 'text', + default: 'N7AX' + } + ], + _transKey: function(options) { + const k = options && options.key !== undefined && options.key !== null + ? String(options.key) + : null; + return (k || this.key || 'N7AX').toUpperCase().replace(/[^A-Z0-9]/g, ''); + }, + square: [ + ['A', 'B', 'C', 'D', 'E'], + ['F', 'G', 'H', 'I', 'K'], + ['L', 'M', 'N', 'O', 'P'], + ['Q', 'R', 'S', 'T', 'U'], + ['V', 'W', 'X', 'Y', 'Z'] + ], + coords: ['N', '7', 'A', 'X', '5'], + _toCoordText: function(text) { + let result = ''; + for (const char of text) { + let found = false; + for (let row = 0; row < 5; row++) { + for (let col = 0; col < 5; col++) { + if (this.square[row][col] === char || (char === 'J' && this.square[row][col] === 'I')) { + result += this.coords[row] + this.coords[col]; + found = true; + break; + } + } + if (found) { + break; + } + } + } + return result; + }, + _transposeEncode: function(text, transKey) { + const keyLength = transKey.length; + const numCols = keyLength; + const numRows = Math.ceil(text.length / numCols); + const grid = []; + let textIdx = 0; + + for (let row = 0; row < numRows; row++) { + grid[row] = []; + for (let col = 0; col < numCols; col++) { + grid[row][col] = textIdx < text.length ? text[textIdx++] : ''; + } + } + + const keyOrder = []; + for (let i = 0; i < transKey.length; i++) { + keyOrder.push({ char: transKey[i], index: i }); + } + keyOrder.sort((a, b) => { + if (a.char < b.char) return -1; + if (a.char > b.char) return 1; + return a.index - b.index; + }); + + let result = ''; + for (const keyItem of keyOrder) { + const col = keyItem.index; + for (let row = 0; row < numRows; row++) { + if (grid[row][col]) { + result += grid[row][col]; + } + } + } + return result; + }, + _transposeDecode: function(text, transKey) { + const keyLength = transKey.length; + const numCols = keyLength; + const numRows = Math.ceil(text.length / numCols); + const keyOrder = []; + + for (let i = 0; i < transKey.length; i++) { + keyOrder.push({ char: transKey[i], index: i }); + } + keyOrder.sort((a, b) => { + if (a.char < b.char) return -1; + if (a.char > b.char) return 1; + return a.index - b.index; + }); + + const grid = []; + for (let row = 0; row < numRows; row++) { + grid[row] = new Array(numCols); + } + + let textIdx = 0; + for (const keyItem of keyOrder) { + const col = keyItem.index; + const colLength = Math.ceil((text.length - col) / numCols); + for (let row = 0; row < colLength && textIdx < text.length; row++) { + grid[row][col] = text[textIdx++]; + } + } + + let result = ''; + for (let row = 0; row < numRows; row++) { + for (let col = 0; col < numCols; col++) { + if (grid[row] && grid[row][col]) { + result += grid[row][col]; + } + } + } + return result; + }, + _fromCoordText: function(text) { + let result = ''; + for (let i = 0; i < text.length; i += 2) { + if (i + 1 >= text.length) { + break; + } + const row = this.coords.indexOf(text[i]); + const col = this.coords.indexOf(text[i + 1]); + if (row >= 0 && row < 5 && col >= 0 && col < 5) { + result += this.square[row][col]; + } + } + return result; + }, + func: function(text, options) { + options = options || {}; + const transKey = this._transKey(options); + if (transKey.length === 0) { + return text; + } + + const cleaned = text.toUpperCase().replace(/[^A-Z]/g, ''); + if (cleaned.length === 0) { + return text; + } + + const coordText = this._toCoordText(cleaned); + return this._transposeEncode(coordText, transKey); + }, + reverse: function(text, options) { + options = options || {}; + const transKey = this._transKey(options); + if (transKey.length === 0) { + return text; + } + + const cleaned = text.toUpperCase().replace(/[^N7AX5]/g, ''); + if (cleaned.length === 0 || cleaned.length % 2 !== 0) { + return text; + } + + const coordText = this._transposeDecode(cleaned, transKey); + return this._fromCoordText(coordText); + }, + preview: function(text, options) { + if (!text) { + return '[n7ax]'; + } + const result = this.func(text.slice(0, 5), options); + return result.substring(0, 12) + (result.length > 12 ? '...' : ''); + }, + detector: function(text) { + const cleaned = text.replace(/[\s]/g, '').toUpperCase(); + if (cleaned.length < 10) { + return false; + } + if (!/^[N7AX5]+$/.test(cleaned)) { + return false; + } + return cleaned.length % 2 === 0; + } +}); diff --git a/src/transformers/encoding/base122.js b/src/transformers/encoding/base122.js index 15ffca3..5a8bcd0 100644 --- a/src/transformers/encoding/base122.js +++ b/src/transformers/encoding/base122.js @@ -1,97 +1,137 @@ -// base122 encoding (more efficient than Base64) +// Base122 encoding (Kevin Albs) — UTF-8 binary-to-text, ~14% smaller than Base64 import BaseTransformer from '../BaseTransformer.js'; -export default new BaseTransformer({ - name: 'Base122', - priority: 250, - category: 'encoding', - func: function(text) { - // Base122 uses UTF-8 bytes and encodes them more efficiently - // It uses 7-bit ASCII (0-127) plus some safe 2-byte UTF-8 sequences - const bytes = new TextEncoder().encode(text); - let result = ''; - - let i = 0; - while (i < bytes.length) { - const byte = bytes[i]; - - if (byte < 128) { - // Single byte ASCII - result += String.fromCharCode(byte); - i++; - } else if (i + 1 < bytes.length) { - // Try to encode as 2-byte sequence - const b1 = byte; - const b2 = bytes[i + 1]; - - // Check if it's a valid 2-byte UTF-8 sequence - if ((b1 & 0xE0) === 0xC0 && (b2 & 0xC0) === 0x80) { - result += String.fromCharCode(b1, b2); - i += 2; - } else { - // Fallback: encode as escaped sequence - result += String.fromCharCode(0xC2, 0x80 + (byte - 128)); - i++; - } - } else { - // Last byte, encode as escaped - result += String.fromCharCode(0xC2, 0x80 + (byte - 128)); - i++; - } - } - - return result; - }, - reverse: function(text) { - const bytes = []; - let i = 0; - - while (i < text.length) { - const code = text.charCodeAt(i); - - if (code < 128) { - bytes.push(code); - i++; - } else if (i + 1 < text.length) { - // Check for 2-byte sequence - const b1 = code; - const b2 = text.charCodeAt(i + 1); - - if ((b1 & 0xE0) === 0xC0 && (b2 & 0xC0) === 0x80) { - // Extract original byte from escaped sequence - if (b1 === 0xC2 && b2 >= 0x80 && b2 < 0xC0) { - bytes.push(b2 - 0x80); - } else { - bytes.push(b1, b2); - } - i += 2; - } else { - bytes.push(code); - i++; - } - } else { - bytes.push(code); - i++; - } - } - - try { - return new TextDecoder().decode(new Uint8Array(bytes)); - } catch (e) { - return ''; - } - }, - preview: function(text) { - if (!text) return '[base122]'; - const result = this.func(text.slice(0, 10)); - return result.substring(0, 15) + '...'; - }, - detector: function(text) { - // Base122 produces text that's mostly ASCII with some UTF-8 sequences - // Hard to detect reliably, but check for mix of ASCII and UTF-8 - const hasAscii = /[\x00-\x7F]/.test(text); - const hasUtf8 = /[\xC0-\xFF]/.test(text); - return hasAscii && text.length >= 8; - } -}); +export default (function() { + const kIllegals = [ + 0, 10, 13, 34, 38, 92 + ]; + const kShortened = 0b111; + function encodeBase122Bytes(bytes) { + let curIndex = 0; + let curBit = 0; + const outData = []; + + function get7() { + if (curIndex >= bytes.length) { + return false; + } + + const firstByte = bytes[curIndex]; + let firstPart = ((0b11111110 >>> curBit) & firstByte) << curBit; + firstPart >>= 1; + curBit += 7; + + if (curBit < 8) { + return firstPart; + } + + curBit -= 8; + curIndex++; + + if (curIndex >= bytes.length) { + return firstPart; + } + + const secondByte = bytes[curIndex]; + let secondPart = ((0xFF00 >>> curBit) & secondByte) & 0xFF; + secondPart >>= 8 - curBit; + return firstPart | secondPart; + } + + while (true) { + const bits = get7(); + if (bits === false) { + break; + } + + const illegalIndex = kIllegals.indexOf(bits); + if (illegalIndex !== -1) { + let nextBits = get7(); + let b1 = 0b11000010; + let b2 = 0b10000000; + + if (nextBits === false) { + b1 |= (kShortened & 0b111) << 2; + nextBits = bits; + } else { + b1 |= (illegalIndex & 0b111) << 2; + } + + const firstBit = (nextBits & 0b01000000) > 0 ? 1 : 0; + b1 |= firstBit; + b2 |= nextBits & 0b00111111; + outData.push(b1, b2); + } else { + outData.push(bits); + } + } + + return new TextDecoder('utf-8').decode(new Uint8Array(outData)); + } + + function decodeBase122String(strData) { + const decoded = []; + let curByte = 0; + let bitOfByte = 0; + + function push7(byte) { + byte <<= 1; + curByte |= (byte >>> bitOfByte); + bitOfByte += 7; + + if (bitOfByte >= 8) { + decoded.push(curByte); + bitOfByte -= 8; + curByte = (byte << (7 - bitOfByte)) & 255; + } + } + + for (let i = 0; i < strData.length; i++) { + const c = strData.charCodeAt(i); + + if (c > 127) { + const illegalIndex = (c >>> 8) & 7; + if (illegalIndex !== kShortened) { + push7(kIllegals[illegalIndex]); + } + push7(c & 127); + } else { + push7(c); + } + } + + return new Uint8Array(decoded); + } + + return new BaseTransformer({ + name: 'Base122', + priority: 250, + category: 'encoding', + func: function(text) { + const bytes = new TextEncoder().encode(text); + return encodeBase122Bytes(bytes); + }, + reverse: function(text) { + try { + const bytes = decodeBase122String(text); + return new TextDecoder().decode(bytes); + } catch (e) { + return ''; + } + }, + preview: function(text) { + if (!text) { + return '[base122]'; + } + const result = this.func(text.slice(0, 10)); + return result.substring(0, 15) + (result.length > 15 ? '...' : ''); + }, + detector: function(text) { + if (!text || text.length < 4) { + return false; + } + return /[\u0080-\uFFFF]/.test(text) || (text.length >= 8 && text !== text.trim()); + } + }); +})(); diff --git a/src/transformers/encoding/baudot.js b/src/transformers/encoding/baudot.js index 624e942..fdc449f 100644 --- a/src/transformers/encoding/baudot.js +++ b/src/transformers/encoding/baudot.js @@ -1,151 +1,197 @@ -// baudot code / ITA2 encoding (teletype code) +// Baudot / ITA2 telegraph code — 5-bit letters/figures with shift codes import BaseTransformer from '../BaseTransformer.js'; -export default new BaseTransformer({ - name: 'Baudot Code (ITA2)', - priority: 250, - category: 'encoding', - // Baudot/ITA2 5-bit code (letters and figures shift) - letters: { - 0b00000: ' ', // NULL/blank - 0b00010: 'E', - 0b00011: '\n', // Line feed - 0b00100: 'A', - 0b00101: ' ', - 0b00110: 'S', - 0b00111: 'I', - 0b01000: 'U', - 0b01001: '\r', // Carriage return - 0b01010: 'D', - 0b01011: 'R', - 0b01100: 'J', - 0b01101: 'N', - 0b01110: 'F', - 0b01111: 'C', - 0b10000: 'K', - 0b10001: 'T', - 0b10010: 'Z', - 0b10011: 'L', - 0b10100: 'W', - 0b10101: 'H', - 0b10110: 'Y', - 0b10111: 'P', - 0b11000: 'Q', - 0b11001: 'O', - 0b11010: 'B', - 0b11011: 'G', - 0b11100: 'Figures', // Shift to figures - 0b11101: 'M', - 0b11110: 'X', - 0b11111: 'V', - }, - figures: { - 0b00000: ' ', - 0b00010: '3', - 0b00011: '\n', - 0b00100: '-', - 0b00101: ' ', - 0b00110: '\'', - 0b00111: '8', - 0b01000: '7', - 0b01001: '\r', - 0b01010: '\u0005', // ENQ - 0b01011: '4', - 0b01100: '\'', // Bell - 0b01101: ',', - 0b01110: '!', - 0b01111: ':', - 0b10000: '(', - 0b10001: '5', - 0b10010: '+', - 0b10011: ')', - 0b10100: '2', - 0b10101: '$', - 0b10110: '6', - 0b10111: '0', - 0b11000: '1', - 0b11001: '9', - 0b11010: '?', - 0b11011: '&', - 0b11100: 'Letters', // Shift to letters - 0b11101: '.', - 0b11110: '/', - 0b11111: '=', - }, - func: function(text) { - // Create reverse maps - const lettersToCode = {}; - const figuresToCode = {}; - for (const [code, char] of Object.entries(this.letters)) { - if (char !== 'Figures' && char !== 'Letters') { - lettersToCode[char] = parseInt(code); - } - } - for (const [code, char] of Object.entries(this.figures)) { - if (char !== 'Figures' && char !== 'Letters') { - figuresToCode[char] = parseInt(code); - } - } - - let result = ''; - let inFigures = false; - - for (const char of text.toUpperCase()) { - // Check if we need to shift - const isFigure = /[0-9\-'():!$?&.\/+=]/.test(char); - - if (isFigure && !inFigures) { - result += String.fromCharCode(0b11100); // Figures shift - inFigures = true; - } else if (!isFigure && inFigures) { - result += String.fromCharCode(0b11111); // Letters shift (approximate) - inFigures = false; - } - - // Encode character - const code = inFigures ? figuresToCode[char] : lettersToCode[char]; - if (code !== undefined) { - result += String.fromCharCode(code); - } else { - result += char; // Keep unmapped - } - } - - return result; - }, - reverse: function(text) { - let result = ''; - let inFigures = false; - - for (let i = 0; i < text.length; i++) { - const code = text.charCodeAt(i) & 0x1F; // 5 bits - - if (code === 0b11100) { - inFigures = true; - continue; - } else if (code === 0b11111) { - inFigures = false; - continue; - } - - const map = inFigures ? this.figures : this.letters; - const char = map[code]; - if (char && char !== 'Figures' && char !== 'Letters') { - result += char; - } - } - - return result; - }, - preview: function(text) { - if (!text) return '[baudot]'; - return this.func(text.slice(0, 5)); - }, - detector: function(text) { - // Baudot uses 5-bit codes (0-31) - // Check for characters in the 5-bit range - const has5Bit = /[\x00-\x1F]/.test(text); - return has5Bit && text.length >= 5; - } -}); +export default (function() { + const FIGS = 0b11011; + const LTRS = 0b11111; + const LETTERS = { + 'A': 0b00100, + 'B': 0b11010, + 'C': 0b01111, + 'D': 0b01010, + 'E': 0b00010, + 'F': 0b01110, + 'G': 0b00001, + 'H': 0b10101, + 'I': 0b00111, + 'J': 0b01100, + 'K': 0b10000, + 'L': 0b10011, + 'M': 0b11101, + 'N': 0b01101, + 'O': 0b11001, + 'P': 0b10111, + 'Q': 0b11000, + 'R': 0b01011, + 'S': 0b00110, + 'T': 0b10001, + 'U': 0b01000, + 'V': 0b11100, + 'W': 0b10100, + 'X': 0b11110, + 'Y': 0b10110, + 'Z': 0b10010 + }; + + const FIGURES = { + '-': 0b00100, + '?': 0b11010, + ':': 0b01111, + '$': 0b01010, + '3': 0b00010, + '!': 0b01110, + '&': 0b11011, + '8': 0b00111, + '7': 0b01000, + '4': 0b01011, + ',': 0b01101, + '(': 0b10000, + ')': 0b10011, + '.': 0b11101, + '0': 0b10111, + '1': 0b11000, + '9': 0b11001, + '5': 0b10001, + '+': 0b10010, + '2': 0b10100, + '6': 0b10110, + '/': 0b11110 + }; + + const LETTER_BY_CODE = {}; + const FIGURE_BY_CODE = {}; + + for (const [char, code] of Object.entries(LETTERS)) { + LETTER_BY_CODE[code] = char; + } + for (const [char, code] of Object.entries(FIGURES)) { + if (char !== '&') { + FIGURE_BY_CODE[code] = char; + } + } + FIGURE_BY_CODE[FIGS] = '&'; + + function isFigureChar(char) { + return Object.prototype.hasOwnProperty.call(FIGURES, char); + } + + function isLetterChar(char) { + return Object.prototype.hasOwnProperty.call(LETTERS, char); + } + + function codesToDisplay(codes) { + return codes.map(code => code.toString(2).padStart(5, '0')).join(' '); + } + + function parseDisplayCodes(text) { + return text.trim().split(/\s+/).map(token => { + if (!/^[01]{5}$/.test(token)) { + return null; + } + return parseInt(token, 2); + }).filter(code => code !== null); + } + + return new BaseTransformer({ + name: 'Baudot Code (ITA2)', + priority: 250, + category: 'encoding', + func: function(text) { + const upper = text.toUpperCase(); + const codes = []; + let inFigures = false; + + for (const char of upper) { + if (char === '\n') { + codes.push(0b00011); + continue; + } + if (char === '\r') { + codes.push(0b01001); + continue; + } + if (char === ' ') { + codes.push(0b00101); + continue; + } + + const wantsFigure = isFigureChar(char); + const wantsLetter = isLetterChar(char); + + if (wantsFigure && !inFigures) { + codes.push(FIGS); + inFigures = true; + } else if (wantsLetter && inFigures && char !== '&') { + codes.push(LTRS); + inFigures = false; + } + + if (char === '&' && !inFigures) { + codes.push(FIGS); + inFigures = true; + } + + const map = inFigures ? FIGURES : LETTERS; + if (Object.prototype.hasOwnProperty.call(map, char)) { + codes.push(map[char]); + } + } + + return codesToDisplay(codes); + }, + reverse: function(text) { + const codes = parseDisplayCodes(text); + if (codes.length === 0) { + return ''; + } + + let result = ''; + let inFigures = false; + + for (const code of codes) { + if (code === FIGS && !inFigures) { + inFigures = true; + continue; + } + if (code === LTRS && inFigures) { + inFigures = false; + continue; + } + if (code === 0b00011) { + result += '\n'; + continue; + } + if (code === 0b01001) { + result += '\r'; + continue; + } + if (code === 0b00101) { + result += ' '; + continue; + } + + const map = inFigures ? FIGURE_BY_CODE : LETTER_BY_CODE; + const char = map[code]; + if (char) { + result += char; + } + } + + return result; + }, + preview: function(text) { + if (!text) { + return '[baudot]'; + } + return this.func(text.slice(0, 5)); + }, + detector: function(text) { + const tokens = text.trim().split(/\s+/); + if (tokens.length < 4) { + return false; + } + return tokens.every(token => /^[01]{5}$/.test(token)); + } + }); +})(); diff --git a/templates/spellingalphabet.html b/templates/spellingalphabet.html new file mode 100644 index 0000000..a2d9433 --- /dev/null +++ b/templates/spellingalphabet.html @@ -0,0 +1,129 @@ +
+
+
+
+
+ +

Custom Spelling Alphabets

+
+

+ Build ICAO-style spelling alphabets from any theme. Generate words with OpenRouter, or fill in all 26 letters manually. + Saved alphabets appear on the Transforms page under custom_spelling. +

+
+ + +
+
+ +
+ +
+

No saved spelling alphabets yet. Create one manually or generate from a category with OpenRouter.

+
+ +
+
+
+

{{ entry.name }}

+

Category: {{ entry.category }}

+

+ Sample (hello world): + {{ saSampleForEntry(entry) }} +

+
+
+ + +
+
+
+
+ + +
+
+ +
+ +
+ + + +
+ +
+ +

+ + No OpenRouter key — add one in Advanced Settings, or type each letter below manually. +

+

+ + Defaults to free OpenRouter models (no credits needed). Generation fills the grid below — edit any letter before saving. +

+
+ +
{{ saError }}
+ +
+ {{ saFilledLetterCount() }} / 26 letters filled + — ready to save +
+ +
+ +
+ +
+ Preview (hello world): {{ saPreviewSample() }} +
+ +
+ + +
+
+
+
+
diff --git a/tests/test_universal.js b/tests/test_universal.js index 361dde7..8615c5c 100644 --- a/tests/test_universal.js +++ b/tests/test_universal.js @@ -463,6 +463,11 @@ const limitations = { acceptPartial: true, normalize: { stripNonLetters: true, stripWhitespace: true } }, + 'n7ax': { + issues: 'Requires key, only encodes A-Z, removes spaces and punctuation', + acceptPartial: true, + normalize: { stripNonLetters: true, stripWhitespace: true } + }, 'polybius': { issues: 'Only encodes A-Z, removes spaces and punctuation', acceptPartial: true,