mirror of
https://github.com/elder-plinius/P4RS3LT0NGV3.git
synced 2026-09-04 17:16:36 +02:00
Merge pull request #25 from ph1r3574r73r/main
P4RS3LT0NGV3 4.0 — themes, transform catalog expansion, new tools, and routing
This commit is contained in:
@@ -8,7 +8,7 @@ This workflow automatically builds and deploys the project to GitHub Pages whene
|
||||
|
||||
1. **Build Stage:**
|
||||
- Checks out the repository
|
||||
- Sets up Node.js (v20) with npm cache enabled
|
||||
- Sets up Node.js (v24) with npm cache enabled
|
||||
- Installs dependencies with `npm ci`
|
||||
- Runs test suite with `npm run test:all`
|
||||
- Runs `npm run build` which:
|
||||
|
||||
@@ -24,12 +24,12 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: '20'
|
||||
node-version: '24'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
@@ -52,7 +52,7 @@ jobs:
|
||||
echo "✅ All critical build files present"
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
uses: actions/upload-pages-artifact@v4
|
||||
with:
|
||||
path: 'dist/'
|
||||
retention-days: 7
|
||||
@@ -67,4 +67,4 @@ jobs:
|
||||
steps:
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
uses: actions/deploy-pages@v5
|
||||
|
||||
+28
-9
@@ -13,6 +13,7 @@ P4RS3LT0NGV3/
|
||||
├── index.template.html # HTML shell; tool *script* tags updated by inject-tool-scripts
|
||||
├── css/
|
||||
│ ├── style.css
|
||||
│ ├── themes-atmosphere.css # Optional per-theme atmosphere / component overrides
|
||||
│ └── notification.css
|
||||
├── js/
|
||||
│ ├── app.js # Vue app entry
|
||||
@@ -37,7 +38,8 @@ P4RS3LT0NGV3/
|
||||
│ │ ├── glitchTokens.js
|
||||
│ │ ├── history.js
|
||||
│ │ ├── notifications.js
|
||||
│ │ └── theme.js
|
||||
│ │ ├── openrouterModels.js # Fetch/cache OpenRouter model lists for Settings UI
|
||||
│ │ └── theme.js # Theme registry + applyTheme / cycleTheme
|
||||
│ └── tools/ # One *Tool.js per tab (extends Tool.js)
|
||||
│ ├── Tool.js
|
||||
│ ├── AntiClassifierTool.js
|
||||
@@ -57,13 +59,14 @@ P4RS3LT0NGV3/
|
||||
│ └── transformers/ # Transformer sources → bundled to dist/js/bundles/
|
||||
│ ├── BaseTransformer.js
|
||||
│ ├── index.js # Generated by npm run build:index (gitignored)
|
||||
│ ├── ancient/
|
||||
│ ├── case/
|
||||
│ ├── cipher/
|
||||
│ ├── concealment/
|
||||
│ ├── encoding/
|
||||
│ ├── fantasy/
|
||||
│ ├── format/
|
||||
│ ├── signwriting/
|
||||
│ ├── special/
|
||||
│ ├── symbol/
|
||||
│ ├── technical/
|
||||
│ ├── unicode/
|
||||
│ └── visual/
|
||||
@@ -89,14 +92,16 @@ P4RS3LT0NGV3/
|
||||
│ ├── fetch-glitch-data.js
|
||||
│ ├── inject-tool-scripts.js # Discovers tools; updates index.template.html + toolRegistry
|
||||
│ ├── inject-tool-templates.js # Builds dist/index.html from index.template.html + templates/
|
||||
│ └── readme-transform-section.js # Maintainer helper for README transform list
|
||||
│ ├── build-code-vendor.js # Bundles QR/barcode libs → js/vendor/
|
||||
│ └── build-alphabet-transforms.js # Regenerates symbol/*.js from data/alphabets/
|
||||
├── tests/
|
||||
│ ├── test_universal.js
|
||||
│ └── test_steganography_options.js
|
||||
├── docs/
|
||||
│ ├── TOOL-SYSTEM.md
|
||||
│ ├── TOOL_ARCHITECTURE.md
|
||||
│ └── UI-COMPONENTS.md
|
||||
│ ├── UI-COMPONENTS.md
|
||||
│ └── THEMES.md # Adding / editing themes
|
||||
├── README.md
|
||||
└── CONTRIBUTING.md
|
||||
|
||||
@@ -116,7 +121,7 @@ dist/ # npm run build — gitignored
|
||||
|
||||
- **`js/core/`** — Shared business logic and infrastructure (not tab-specific)
|
||||
- Examples: `decoder.js` (DecodeTool, decoder pipeline), `steganography.js` (EmojiTool, steg engine), `toolRegistry.js` (registers tools, merges Vue surface), `transformOptions.js` (shared transform UI helpers)
|
||||
- **`js/utils/`** — Cross-cutting helpers (`clipboard`, `EmojiUtils` in `emoji.js`, notifications, theme, etc.)
|
||||
- **`js/utils/`** — Cross-cutting helpers (`clipboard`, `EmojiUtils` in `emoji.js`, notifications, `theme.js`, `openrouterModels.js`, etc.)
|
||||
- **`js/data/`** — Committed static payloads (models, prompts, glitch token data, end sequences, `emojiCompatibility.js`). **`emojiData.js`** is **not** edited here — it is **generated** to `dist/js/data/emojiData.js` by `npm run build:emoji`.
|
||||
- **`src/`** — `emojiWordMap.js` feeds the emoji build; `transformers/` holds transformer modules
|
||||
- **Generated bundle** — `npm run build:transforms` writes `dist/js/bundles/transforms-bundle.js` (a legacy `js/bundles/transforms-bundle.js` path may exist for older workflows and is gitignored)
|
||||
@@ -172,7 +177,6 @@ Transformers are the core text transformation logic. See `src/transformers/READM
|
||||
export default new BaseTransformer({
|
||||
name: 'My Cipher',
|
||||
priority: 60, // See priority guide in transformers/README.md
|
||||
category: 'ciphers',
|
||||
func: function(text) {
|
||||
// Encoding logic
|
||||
return encoded;
|
||||
@@ -203,7 +207,10 @@ Transformers are the core text transformation logic. See `src/transformers/READM
|
||||
- Add test cases to `tests/test_universal.js`
|
||||
- Run `npm test` to verify
|
||||
|
||||
**Important:** Transformers are automatically discovered and bundled. No manual registration needed!
|
||||
6. Update documentation:
|
||||
- Add a bullet to the matching category under **Text Transformations** in the root `README.md`
|
||||
|
||||
**Important:** Transformers are automatically discovered and bundled. Category is set from the parent folder at build time — no manual registration needed.
|
||||
|
||||
### 2. Adding a New Tool (New Tab/Feature)
|
||||
|
||||
@@ -309,7 +316,18 @@ Utilities are shared helper functions used across the app. Currently, utility fu
|
||||
- Document with JSDoc comments
|
||||
- Consider adding to existing modules if functionality is related
|
||||
|
||||
**Note:** Prefer `js/utils/` for shared helpers (clipboard, emoji, escapeParser, focus, glitchTokens, history, notifications, theme). Use `js/config/` for constants.
|
||||
**Note:** Prefer `js/utils/` for shared helpers (clipboard, emoji, escapeParser, focus, glitchTokens, history, notifications, theme, openrouterModels). Use `js/config/` for constants.
|
||||
|
||||
### 4. Adding or Updating a Theme
|
||||
|
||||
Themes are registry entries plus CSS token blocks—not Vue components.
|
||||
|
||||
1. Register the theme in `js/utils/theme.js` (`themes` array).
|
||||
2. Add a `body.theme-<id> { … }` token block in `css/style.css` (copy from `body.theme-dark` or an existing custom theme).
|
||||
3. Optionally add atmosphere rules in `css/themes-atmosphere.css`.
|
||||
4. `npm run build`, then verify in the browser (dropdown, **`D`** cycle, nav, utility dock, toggles, mobile header utility button).
|
||||
|
||||
Full step-by-step guide, token checklist, and testing list: **[docs/THEMES.md](docs/THEMES.md)**.
|
||||
|
||||
## 🧪 Testing
|
||||
|
||||
@@ -413,6 +431,7 @@ This:
|
||||
- **Tool system**: `docs/TOOL-SYSTEM.md` — Templates, injection, UI vocabulary
|
||||
- **Tool architecture**: `docs/TOOL_ARCHITECTURE.md`
|
||||
- **UI components**: `docs/UI-COMPONENTS.md`
|
||||
- **Themes**: `docs/THEMES.md` — Adding and editing themes
|
||||
|
||||
## ✅ Checklist Before Submitting
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# 🐍 P4RS3LT0NGV3 - Universal Text Translator
|
||||
# 🐍 P4RS3LT0NGV3 4.0 — Universal Text Translator
|
||||
|
||||
A powerful web-based text transformation and steganography tool with **159** built-in text transforms spanning encodings, classical and modern ciphers, Unicode styles, formatting, and niche alphabets. Think of it as a universal translator for ALL alphabets and writing systems!
|
||||
A powerful web-based text transformation and steganography tool with **222** built-in text transforms spanning encodings, classical and modern ciphers, Unicode styles, formatting, and niche alphabets. Think of it as a universal translator for ALL alphabets and writing systems!
|
||||
|
||||
**Version 4.0** brings a redesigned desktop app shell, seven themes (including WCAG 2.1 AA **Accessible**), mobile utility panels, OpenRouter model curation, and responsive UI polish across all tools.
|
||||
|
||||
The app is a **static site**: run **`npm run build`** (after `npm install`), then open **`dist/index.html`** in your browser—no local server required. **Alternatively**, you can run it as a local app over HTTP with **`npm start`** or **`npx serve dist -l 8080`** (see [Getting Started](#getting-started) below). Core transforms, decoder, and steganography work **without** calling the cloud; features that use [OpenRouter](https://openrouter.ai/) need **network access** and an API key (see below).
|
||||
|
||||
@@ -15,22 +17,22 @@ The app is a **static site**: run **`npm run build`** (after `npm install`), the
|
||||
|
||||
Categories match the Transform tab and the folders under `src/transformers/` (each transformer’s `name` as shown in the UI). Short descriptions explain what each transform does.
|
||||
|
||||
#### **Ancient**
|
||||
- **Elder Futhark** - Germanic Elder Futhark runes
|
||||
- **Hieroglyphics** - Egyptian hieroglyph-style mapping
|
||||
- **Ogham (Celtic)** - Celtic Ogham tree alphabet
|
||||
- **Roman Numerals** - Arabic numerals ↔ Roman numerals
|
||||
|
||||
#### **Case**
|
||||
- **Alternating Case** - Alternate uppercase and lowercase per letter (first letter upper or lower)
|
||||
- **camelCase** - lowerCamelCase for identifiers
|
||||
- **Capitalize Words** - Capitalize the first letter of each word
|
||||
- **kebab-case** - kebab-case for slugs and identifiers
|
||||
- **Lowercase All** - Lowercase entire text
|
||||
- **Random Case** - Random casing per character
|
||||
- **Sentence Case** - Capitalize the first letter of each sentence
|
||||
- **snake_case** - snake_case for identifiers
|
||||
- **Title Case** - Capitalize each word
|
||||
- **Toggle Case** - Swap case of each letter
|
||||
- **Uppercase All** - Uppercase entire text
|
||||
|
||||
#### **Cipher**
|
||||
- **A1Z26** - A=1 … Z=26 letter numbering
|
||||
- **Acéré Cipher** - Solfege / duration encoding for musical steganography
|
||||
- **ADFGX Cipher** - WWI ADFGVX-style polybius + column transposition
|
||||
- **Affine Cipher** - Affine substitution (ax + b mod 26)
|
||||
- **Atbash Cipher** - Reverse-alphabet substitution (A↔Z)
|
||||
@@ -39,16 +41,21 @@ Categories match the Transform tab and the folders under `src/transformers/` (ea
|
||||
- **Beaufort Cipher** - Beaufort key-table polyalphabetic cipher
|
||||
- **Bifid Cipher** - Polybius square + row/column interleaving
|
||||
- **Caesar Cipher** - Classic alphabet shift (configurable)
|
||||
- **Codons (Genetic Code)** - Letters A–Z as DNA triplets
|
||||
- **Columnar Transposition** - Columnar transposition with a keyword
|
||||
- **Double Transposition** - Two keyed columnar transpositions
|
||||
- **Four-Square Cipher** - Four 5×5 squares; digraph substitution
|
||||
- **Fractionated Morse** - Morse with fractionation pattern
|
||||
- **Gronsfeld Cipher** - Vigenère family with numeric key
|
||||
- **Hill Cipher** - Matrix-based multi-letter substitution
|
||||
- **Homophonic Cipher** - Multiple ciphertext symbols per plaintext letter
|
||||
- **Multiplicative Cipher** - Multiply by key mod 26
|
||||
- **Nihilist Cipher** - Keyed Polybius + additive encryption
|
||||
- **Pigpen Cipher** - Masonic / pigpen grid symbols
|
||||
- **Playfair Cipher** - Digraph cipher on a 5×5 square
|
||||
- **Polybius Square** - Letter ↔ grid coordinates
|
||||
- **Porta Cipher** - Porta table polyalphabetic cipher
|
||||
- **QWERTY Right Shift** - Map keys to the key to the right on QWERTY
|
||||
- **Route Cipher** - Read ciphertext along a grid route
|
||||
- **Rail Fence** - Zig-zag rail-fence transposition
|
||||
- **ROT128** - UTF-16 code unit rotation by 128
|
||||
- **ROT13** - Rotate Latin letters by 13 places
|
||||
@@ -57,11 +64,24 @@ Categories match the Transform tab and the folders under `src/transformers/` (ea
|
||||
- **ROT5** - Rotate digits 0–9 by 5
|
||||
- **ROT8000** - Plane-0 Unicode BMP rotation cipher
|
||||
- **Scytale Cipher** - Wrap-around strip (scytale) transposition
|
||||
- **Tap Code** - Polybius / tap / prison code
|
||||
- **Trifid Cipher** - Three Polybius cubes + trifid grouping
|
||||
- **Trithemius Cipher** - Progressive Caesar (tabula recta)
|
||||
- **Two-Square Cipher** - Digraph cipher with two Playfair squares
|
||||
- **Vernam Cipher** - One-time pad XOR (mod 26)
|
||||
- **Vigenère Cipher** - Polyalphabetic cipher with repeating keyword
|
||||
- **XOR Cipher** - XOR with a repeating key
|
||||
|
||||
#### **Concealment**
|
||||
- **Acrostic** - First letter of each line or word spells a message
|
||||
- **Cardan Grille** - Hide text through a rotating grille
|
||||
- **Homoglyph Generator** - Latin letters to Cyrillic homoglyphs
|
||||
- **Invisible Text** - Unicode Tags / invisible carrier encoding
|
||||
- **Null Cipher** - Fixed letter position in each cover word
|
||||
- **Trevanion Cipher** - Letters N positions after punctuation marks
|
||||
- **Whitespace Steganography** - Hide bits in whitespace patterns
|
||||
- **Zero-Width Steganography** - Hide data with zero-width characters
|
||||
|
||||
#### **Encoding**
|
||||
- **ASCII85** - Ascii85 / Adobe-style base-85 encoding
|
||||
- **Base122** - Binary → 122 printable ASCII characters
|
||||
@@ -73,46 +93,39 @@ Categories match the Transform tab and the folders under `src/transformers/` (ea
|
||||
- **Base64** - Standard Base64
|
||||
- **Base64 URL** - Base64url (URL-safe alphabet)
|
||||
- **Base91** - basE91 / Ascii91 encoding
|
||||
- **Bibi-binary Code** - UTF-8 bytes via Bibi-binary syllables
|
||||
- **Baudot Code (ITA2)** - Five-bit telegraph / ITA2
|
||||
- **Binary Coded Decimal** - Decimal digits as BCD nibbles
|
||||
- **Binary** - Text bytes ↔ binary strings
|
||||
- **Bitwise NOT** - UTF-8 bytes NOT'd per byte; encode output is hex (decode pastes hex back to text)
|
||||
- **Brainfuck** - Text ↔ Brainfuck program
|
||||
- **Decabit Code** - Ten-bit patterns for decimal digits
|
||||
- **EBCDIC** - EBCDIC byte encoding
|
||||
- **Emoji Encoding** - Payload encoded with emoji
|
||||
- **Base256Emoji** - Multiformats multibase encoding (1 byte → 1 emoji)
|
||||
- **Gray Code** - Binary Gray code
|
||||
- **Hexadecimal** - Hex encode/decode bytes
|
||||
- **HTML Entities** - HTML entity escape / unescape
|
||||
- **Invisible Text** - Unicode Tags / invisible carrier encoding
|
||||
- **Manchester Code** - Manchester line coding
|
||||
- **Metaphone** - Metaphone phonetic encoding
|
||||
- **Quoted-Printable** - MIME quoted-printable
|
||||
- **Shadoks Numeral System** - UTF-8 bytes as Shadoks base-4 words
|
||||
- **Unicode Code Points** - Characters ↔ U+XXXX code points
|
||||
- **URL Encode** - application/x-www-form-urlencoded
|
||||
- **Uuencoding** - Classic uuencode / uudecode
|
||||
- **YEnc** - yEnc line-oriented binary encoding
|
||||
- **Z85** - ZeroMQ Z85 encoding
|
||||
|
||||
#### **Fantasy**
|
||||
- **Aurebesh (Star Wars)** - Galactic Basic Aurebesh alphabet
|
||||
- **Dovahzul (Dragon)** - Skyrim Dovahzul transliteration
|
||||
- **Klingon** - Klingon transliteration
|
||||
- **Quenya (Tolkien Elvish)** - Tolkien Quenya mapping
|
||||
- **Tengwar Script** - Elvish Tengwar script
|
||||
|
||||
#### **Format**
|
||||
- **Bitwise NOT** - UTF-8 bytes NOT'd per byte; encode output is hex (decode pastes hex back to text)
|
||||
- **Boustrophedon** - Serpentine / alternating line direction
|
||||
- **Capitalize Words** - Capitalize the first letter of each word
|
||||
- **Group Letters** - Insert separators between letters
|
||||
- **Indent** - Add leading spaces to each line (configurable width)
|
||||
- **Javanais** - French “javanais” vowel-insertion game
|
||||
- **Latin Gibberish** - Latin-flavored pseudo-text
|
||||
- **Leetspeak** - 1337-style character substitutions
|
||||
- **Letters Only** - Keep letters; strip other characters
|
||||
- **Letters & Numbers Only** - Alphanumeric only
|
||||
- **Line Numbers** - Prefix lines with numbers (start and column width configurable)
|
||||
- **Louchebem** - French argot (loucherbem-style)
|
||||
- **Lowercase All** - Lowercase entire text
|
||||
- **Leading Zeros** - Pad numbers with leading zeros
|
||||
- **List Deduplicate** - Remove duplicate lines from a list
|
||||
- **Mirror Digits** - Mirror digits 0–9 visually
|
||||
- **Numbers Only** - Digits only
|
||||
- **Pig Latin** - English Pig Latin
|
||||
- **QWERTY Right Shift** - Map keys to the key to the right on QWERTY
|
||||
- **Remove Accents** - Strip diacritics / combining marks
|
||||
- **Remove Consonants** - Remove consonant letters
|
||||
- **Remove Duplicates** - Remove duplicate lines
|
||||
@@ -127,35 +140,83 @@ Categories match the Transform tab and the folders under `src/transformers/` (ea
|
||||
- **Reverse Text** - Reverse character order
|
||||
- **Shuffle Characters** - Shuffle characters (random order)
|
||||
- **Shuffle Words** - Shuffle word order
|
||||
- **Shuffled Letters** - Randomize letter order within each word
|
||||
- **Spaces Remover** - Remove space characters
|
||||
- **Text Justify** - Pad each line to a fixed width (left, right, or center); not word-spacing justify
|
||||
- **Uppercase All** - Uppercase entire text
|
||||
- **Toggle Case** - Swap case of each letter
|
||||
- **Whitespace Steganography** - Hide bits in whitespace patterns
|
||||
- **Typoglycemia** - Scrambled middle letters (readable chaos)
|
||||
- **Word Letter Add** - Insert a letter at a fixed position in each word
|
||||
- **Word Letter Change** - Replace a letter at a fixed position in each word
|
||||
- **Word Letter Remove** - Remove a letter at a fixed position in each word
|
||||
- **Word Wrap** - Break long lines at spaces so each line fits a maximum width
|
||||
- **Zero-Width Steganography** - Hide data with zero-width characters
|
||||
|
||||
#### **SignWriting**
|
||||
- **ASL SignWriting** - American Sign Language fingerspelling (ISWA)
|
||||
- **LIBRAS SignWriting** - Brazilian Sign Language fingerspelling
|
||||
- **JSL SignWriting** - Japanese Sign Language (hiragana input)
|
||||
- **IPA Lip-reading** - IPA symbols in SignWriting
|
||||
- **Morse Blink** - Morse as SignWriting blink marks
|
||||
- **Tactile SignWriting** - Tactile SignWriting notation
|
||||
|
||||
#### **Special**
|
||||
- **Random Mix** - Pick random transforms and chain them
|
||||
|
||||
#### **Technical**
|
||||
- **A1Z26** - A=1 … Z=26 letter numbering
|
||||
- **Braille** - Unicode Braille patterns
|
||||
- **Brainfuck** - Text ↔ Brainfuck program
|
||||
- **ICAO Spelling Alphabet** - ICAO radiotelephony spelling
|
||||
- **ITU Spelling Alphabet** - ITU phonetic / spelling alphabet
|
||||
- **DTMF Tones** - Dual-tone multi-frequency telephone codes
|
||||
- **Maritime Signal Flags** - International maritime signal flags
|
||||
- **Morse Code** - International Morse code
|
||||
- **Navajo Code** - WWII Navajo word code for A–Z
|
||||
- **NATO Phonetic** - NATO phonetic alphabet
|
||||
- **Semaphore Flags** - Flag semaphore arm positions
|
||||
- **Tap Code** - Polybius / tap / prison code
|
||||
- **T9 (Predictive Text)** - Phone T9 multi-tap encoding
|
||||
- **Phone Keypad** - Digits from phone keypad groups
|
||||
|
||||
#### **Symbol**
|
||||
- **Alchemical Symbols** - Alchemical symbol alphabet
|
||||
- **Aurebesh (Star Wars)** - Galactic Basic Aurebesh alphabet
|
||||
- **Babylonian Numerals** - A1Z26 as cuneiform numerals
|
||||
- **Braille** - Unicode Braille patterns
|
||||
- **Celestial Alphabet** - Celestial / angelic script
|
||||
- **Chemical Symbols** - Chemical element symbols
|
||||
- **Daedric (Elder Scrolls)** - Daedric alphabet
|
||||
- **Dancing Men (Sherlock Holmes)** - Dancing figure cipher
|
||||
- **Dominos in Digits** - Digits as domino tile notation
|
||||
- **Dovahzul (Dragon)** - Skyrim Dovahzul transliteration
|
||||
- **Egyptian Numerals** - A1Z26 as hieroglyph numerals
|
||||
- **Elder Futhark** - Germanic Elder Futhark runes
|
||||
- **Enochian** - Enochian angelic alphabet
|
||||
- **Eye of Horus (Wedjat)** - Wedjat fraction hieroglyphs
|
||||
- **Friderici Cipher (Windows)** - 1685 window-pane cipher
|
||||
- **Greek Letters** - Greek letter replacements
|
||||
- **Hieroglyphics** - Egyptian hieroglyph-style mapping
|
||||
- **Hiragana** - Rough Romaji → Hiragana
|
||||
- **Klingon** - Klingon transliteration
|
||||
- **Katakana** - Rough Romaji → Katakana
|
||||
- **Malachim** - Malachim angelic alphabet
|
||||
- **Mary Stuart Cipher** - Mary Queen of Scots cipher
|
||||
- **Mayan Numerals** - A1Z26 as Mayan numeral glyphs
|
||||
- **Moon Alphabet** - Moon type for blind readers
|
||||
- **Ogham (Celtic)** - Celtic Ogham tree alphabet
|
||||
- **Passing the River** - Golden Dawn Passing the River script
|
||||
- **Periodic Table Cipher** - Letters A–Z as element symbols
|
||||
- **Pigpen Cipher** - Masonic / pigpen grid symbols
|
||||
- **Quenya (Tolkien Elvish)** - Tolkien Quenya mapping
|
||||
- **Roman Numerals** - Arabic numerals ↔ Roman numerals
|
||||
- **Rosicrucian** - Rosicrucian cipher alphabet
|
||||
- **Seven-Segment Display** - Digits as 7-segment ASCII art
|
||||
- **Standard Galactic (Minecraft)** - Enchanting Table / SGA alphabet
|
||||
- **Templars Cipher** - Templar pigpen variant
|
||||
- **Tengwar Script** - Elvish Tengwar script
|
||||
- **Theban Alphabet** - Witches' Theban script
|
||||
- **Wingdings** - Wingdings-style symbol mapping
|
||||
- **Younger Futhark** - Medieval Younger Futhark runes
|
||||
|
||||
#### **Unicode**
|
||||
- **Bold Italic** - Mathematical sans-serif bold italic
|
||||
- **Bold** - Mathematical bold
|
||||
- **Bubble** - Circled / “bubble” letters
|
||||
- **Chemical Symbols** - Chemical element symbols
|
||||
- **Circled** - Circled Unicode letters
|
||||
- **Bubble** - Circled letters (upper and lower case) and circled digits 0–9
|
||||
- **Circled** - Circled uppercase letters and digits (no lowercase)
|
||||
- **Cursive** - Mathematical script / cursive
|
||||
- **Cyrillic Stylized** - Latin → Cyrillic lookalike letters
|
||||
- **Dashed Underline** - Combining dashed underline
|
||||
@@ -163,10 +224,7 @@ Categories match the Transform tab and the folders under `src/transformers/` (ea
|
||||
- **Double-Struck** - Mathematical double-struck
|
||||
- **Fraktur** - Mathematical Fraktur / Gothic
|
||||
- **Full Width** - Fullwidth Latin (and related) forms
|
||||
- **Greek Letters** - Greek letter replacements
|
||||
- **Hiragana** - Rough Romaji → Hiragana
|
||||
- **Italic** - Mathematical italic
|
||||
- **Katakana** - Rough Romaji → Katakana
|
||||
- **Mathematical Notation** - Mathematical alphanumeric symbols
|
||||
- **Medieval** - Medieval Unicode letterforms
|
||||
- **Mirror Text** - Left–right mirrored characters
|
||||
@@ -185,12 +243,16 @@ Categories match the Transform tab and the folders under `src/transformers/` (ea
|
||||
- **Vaporwave** - Fullwidth + aesthetic spacing
|
||||
- **Wavy Underline** - Wavy underline combining marks
|
||||
- **Wide Spacing** - Insert wide spaces between characters
|
||||
- **Wingdings** - Wingdings-style symbol mapping
|
||||
- **Zalgo** - Stacked combining marks (“glitch” text)
|
||||
|
||||
#### **Visual**
|
||||
- **Disemvowel** - Remove vowels (speech game)
|
||||
- **Emoji Speak** - Emoji-heavy “speak” transform
|
||||
- **Javanais** - French “javanais” vowel-insertion game
|
||||
- **Latin Gibberish** - Latin-flavored pseudo-text
|
||||
- **Leetspeak** - 1337-style character substitutions
|
||||
- **Louchebem** - French argot (loucherbem-style)
|
||||
- **Pig Latin** - English Pig Latin
|
||||
- **Rövarspråket** - Swedish consonant-doubling game
|
||||
- **Ubbi Dubbi** - Insert “ub” before vowel sounds
|
||||
|
||||
@@ -200,7 +262,7 @@ Tabs appear in **UI order** below. **OpenRouter** (optional or required per tool
|
||||
|
||||
### 🔤 **Transform**
|
||||
|
||||
- **159 Transforms**: Encodings, ciphers, Unicode styles, formats, and more (full catalog above).
|
||||
- **222 Transforms**: Encodings, ciphers, Unicode styles, formats, and more (full catalog above).
|
||||
- **Categories**: Grouped sections you can **reorder**; quick-jump legend; **randomizer** last.
|
||||
- **Favorites & last used**: Pin transforms and recall recent picks.
|
||||
- **Per-transform options**: Gear icon where a transform exposes settings.
|
||||
@@ -225,7 +287,7 @@ Tabs appear in **UI order** below. **OpenRouter** (optional or required per tool
|
||||
- **Real-time**: Updates as you type.
|
||||
- **Script & language hints**: Unicode script ranges and Latin word-marker heuristics for common languages.
|
||||
- **AI translate to English** (optional, OpenRouter): When text looks foreign, optional one-shot translate to English.
|
||||
- **Keyboard shortcut**: **D**.
|
||||
- **Deep link**: `#decoder` in the URL opens this tab directly.
|
||||
|
||||
### 😀 **Emoji** (Steganography)
|
||||
|
||||
@@ -266,11 +328,31 @@ 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.
|
||||
- **Removal mode**: Random or **specific** letter removal with batch **variations** and min/max strip lengths.
|
||||
|
||||
### 📷 **Codes** (QR & barcodes)
|
||||
|
||||
*Tab id: `codes` — deep link: `#codes` (generate) or `#codes/decode` (scan).*
|
||||
|
||||
- **Generate**: QR codes and common 1D barcodes from any text or numeric payload.
|
||||
- **Formats**: **QR Code** (PNG download), **Code 128**, **EAN-13**, and **Code 39** (SVG download).
|
||||
- **QR options**: Pixel size, quiet-zone margin, and error-correction level (L / M / Q / H).
|
||||
- **Barcode options**: Bar height, module width, and optional human-readable label under the bars.
|
||||
- **Decode**: Upload a PNG, JPEG, GIF, or WebP image to read QR codes and barcodes — **fully client-side** (image never leaves the browser).
|
||||
- **Copy & reuse**: Send decoded text to the Generate tab with one click.
|
||||
- **Offline-friendly**: QR/barcode libraries (`qrcode`, JsBarcode, ZXing) are bundled into `js/vendor/` at build time — no runtime CDN for this tab.
|
||||
|
||||
### 🪄 **PromptCraft** (via OpenRouter)
|
||||
|
||||
- **9 Mutation Strategies**: Rephrase, Obfuscate, Role-Play Wrap, Multi-Language, Expand, Compress, Metaphor, Fragment, and Custom
|
||||
@@ -286,23 +368,29 @@ models
|
||||
- **Same key**: Uses the same OpenRouter API key as Translation and PromptCraft.
|
||||
|
||||
### 📱 **User Experience**
|
||||
- **Dark/Light Theme**: Toggle between themes
|
||||
- **Themes**: **Advanced Settings** → Theme (Dark, Light, **Accessible**, BT6, Pliny, Cyberpunk, Wild West) — press **`D`** to cycle; choice saved in the browser
|
||||
- **Desktop layout**: Left tool nav, main workspace, right utility dock (Copy History, Glitch Tokens, End Sequences, Advanced Settings)
|
||||
- **Mobile / narrow screens**: Tool picker dropdown; utility panels slide in from the right (open via the **columns** icon in the header)
|
||||
- **Copy History**: Track all copied content with timestamps
|
||||
- **Auto-copy**: Automatically copy transformed text
|
||||
- **Keyboard Shortcuts**: Quick access to features
|
||||
- **Auto-copy**: Automatically copy transformed text (where enabled per tool)
|
||||
- **Keyboard Shortcuts**: Quick access to features (including **`D`** for theme cycle)
|
||||
- **Responsive Design**: Works on all device sizes
|
||||
- **Accessibility**: Screen reader friendly with proper ARIA labels
|
||||
- **Side panels**: Glitch token browser (optional data), end-sequence / delimiter strings for research, and **Advanced Settings** (OpenRouter key, steganography tuning)
|
||||
- **Side panels**: Glitch token browser (optional data), end-sequence / delimiter strings for research, and **Advanced Settings** (OpenRouter API key, model curation, steganography tuning)
|
||||
- **Deep links**: Open a specific tool tab via URL hash — e.g. `#decoder`, `#steganography`, `#codes`, `#codes/decode` (browser back/forward supported)
|
||||
|
||||
Contributors: see **[docs/THEMES.md](docs/THEMES.md)** for how to add or edit themes.
|
||||
|
||||
### 🔑 **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-...`)
|
||||
3. In P4RS3LT0NGV3, click the **sliders icon** (top-right) to open **Advanced Settings**
|
||||
3. In P4RS3LT0NGV3, open **Advanced Settings** from the utility dock (desktop) or the **columns** icon in the header (mobile)
|
||||
4. Paste your key and click **Save Key**
|
||||
5. Your key is stored locally in your browser only — never sent anywhere except OpenRouter
|
||||
5. Choose which OpenRouter models appear in Translation, PromptCraft, Anti-Classifier, and related dropdowns
|
||||
6. Your key is stored locally in your browser only — never sent anywhere except OpenRouter
|
||||
|
||||
> **Tip:** Some models (like Gemma 3) are free on OpenRouter. Frontier models (Claude, GPT, Gemini Pro) require credits.
|
||||
|
||||
@@ -342,11 +430,12 @@ Notes:
|
||||
npm install
|
||||
|
||||
# Build all assets (required before use). Order matches package.json:
|
||||
# build:tools → build:copy → build:index → build:transforms → build:emoji → build:templates
|
||||
# build:tools → build:codes-vendor → build:copy → build:index → build:transforms → build:emoji → build:templates
|
||||
npm run build
|
||||
|
||||
# Or build individual components:
|
||||
npm run build:tools # Auto-discover tools, inject script tags into dist/index.html
|
||||
npm run build:codes-vendor # Bundle qrcode, JsBarcode, ZXing → dist/js/vendor/
|
||||
npm run build:copy # Copy static files to dist/
|
||||
npm run build:index # Generate src/transformers/index.js (ES module index)
|
||||
npm run build:transforms # Bundle all transformers to dist/js/bundles/transforms-bundle.js
|
||||
@@ -373,7 +462,7 @@ npm run preview # npm run build, then serve dist/
|
||||
| [build/README.md](build/README.md) | What each `build:*` script does |
|
||||
| [templates/README.md](templates/README.md) | Editing tool HTML templates |
|
||||
|
||||
**Keeping the transform list in this README in sync:** when you add or rename a transformer, add a one-line description to `DESCRIPTIONS` in `build/readme-transform-section.js`, run `node build/readme-transform-section.js`, and replace the **Text Transformations** section here (details in [src/transformers/README.md](src/transformers/README.md)).
|
||||
**Keeping the transform list in this README in sync:** when you add, rename, or move a transformer, update the **Text Transformations** section below manually — one bullet per transform under the correct category heading (`case`, `cipher`, `concealment`, `encoding`, `format`, `signwriting`, `special`, `symbol`, `technical`, `unicode`, `visual`). Use the transform’s UI `name` and a short description. Category comes from the folder under `src/transformers/` (see [src/transformers/README.md](src/transformers/README.md)).
|
||||
|
||||
## 🛠️ **Technical Details**
|
||||
|
||||
@@ -382,12 +471,13 @@ npm run preview # npm run build, then serve dist/
|
||||
- **Tool System**: Modular tool registry with build-time template injection
|
||||
- **Encoding**: UTF-8 with proper Unicode handling
|
||||
- **Steganography**: Variation selectors and Tags Unicode block
|
||||
- **Transforms**: Individual transformer modules live under `src/transformers/` (159; the bundle is generated by `npm run build:transforms`)
|
||||
- **Transforms**: Individual transformer modules live under `src/transformers/` (222; the bundle is generated by `npm run build:transforms`)
|
||||
- **Build Process**:
|
||||
- `npm run build` writes the runnable app under `dist/` (ignored by git in most setups)
|
||||
- Transformers are bundled from `src/transformers/` to `dist/js/bundles/transforms-bundle.js`
|
||||
- Tool templates are injected from `templates/` into `dist/index.html`
|
||||
- Emoji data is generated to `dist/js/data/`
|
||||
- QR/barcode vendors (`qrcode`, `JsBarcode`, `@zxing/library`) are bundled to `dist/js/vendor/` via `npm run build:codes-vendor`
|
||||
|
||||
### **Browser Support**
|
||||
- Chrome/Edge 80+
|
||||
@@ -400,6 +490,15 @@ npm run preview # npm run build, then serve dist/
|
||||
- **Memory Efficient**: Streams large text without loading into memory
|
||||
- **Optimized Rendering**: Efficient DOM updates with Vue.js
|
||||
|
||||
## 🔧 **What's New in 4.0**
|
||||
|
||||
- 🆕 **Version 4.0 release**: Major UI refresh — desktop three-column layout, theme system, and mobile utility dock
|
||||
- 🆕 **Theme system**: Seven themes (Dark, Light, **Accessible**, BT6, Pliny, Cyberpunk, Wild West) with token-based CSS and atmosphere layers
|
||||
- 🆕 **Desktop app shell**: Left tool nav, main workspace, right utility dock (Copy History, Glitch Tokens, End Sequences, Settings)
|
||||
- 🆕 **Mobile utility panels**: Slide-over panels with responsive tab bar (no horizontal scroll)
|
||||
- 🆕 **OpenRouter model settings**: Curate which models appear in AI tool dropdowns (Settings)
|
||||
- 🆕 **Responsive tool polish**: Bijection, Tokenade, transform cards, and topbar layout improvements
|
||||
|
||||
## 🔧 **Recent Fixes & Improvements**
|
||||
|
||||
### **Fixed Issues**
|
||||
@@ -409,10 +508,12 @@ npm run preview # npm run build, then serve dist/
|
||||
- ✅ **Reverse Functions**: Added missing reverse functions for many transforms
|
||||
|
||||
### **New Features**
|
||||
- 🆕 **Codes tool**: Generate QR codes and barcodes (Code 128, EAN-13, Code 39); decode from uploaded images client-side
|
||||
- 🆕 **URL deep links**: Jump to any tool tab with `#tab` hashes (e.g. `#codes/decode`)
|
||||
- 🆕 **AI Translation**: Translate to 20+ languages (including dead/exotic) via OpenRouter using TranslateGemma prompt format
|
||||
- 🆕 **PromptCraft Tool**: AI-powered prompt mutation with 9 strategies and 48+ models
|
||||
- 🆕 **OpenRouter Integration**: Unified API key management for all AI-powered features
|
||||
- 🆕 **159 Transformations**: Full catalog of encodings, ciphers, Unicode styles, fantasy and ancient scripts, and technical codes (see README transform list)
|
||||
- 🆕 **222 Transformations**: Full catalog of encodings, ciphers, Unicode styles, symbol alphabets, SignWriting, and technical codes (see README transform list)
|
||||
- 🆕 **More Encodings/Ciphers**: Base58, Base62, Vigenère, Rail Fence, Roman Numerals
|
||||
- 🆕 **Category Organization**: Better organized transform categories
|
||||
- 🆕 **Enhanced Styling**: New color schemes for each category
|
||||
@@ -423,7 +524,7 @@ npm run preview # npm run build, then serve dist/
|
||||
### **Creative Writing**
|
||||
- Create unique text styles for stories
|
||||
- Encode secret messages in plain sight
|
||||
- Generate fantasy language text
|
||||
- Generate symbolic or script-style text
|
||||
|
||||
### **Education**
|
||||
- Learn about different writing systems
|
||||
@@ -468,7 +569,8 @@ This project is open source. See LICENSE file for details.
|
||||
- **Star Wars** creators for Aurebesh
|
||||
- **Bethesda** for Dovahzul language
|
||||
- **Unicode Consortium** for character standards
|
||||
- **[RaidedCluster](https://github.com/RaidedCluster)** — SignWriting transforms
|
||||
|
||||
---
|
||||
|
||||
**P4RS3LT0NGV3** - Because sometimes you need to speak in tongues that don't exist! 🐉✨
|
||||
**P4RS3LT0NGV3 4.0** - Because sometimes you need to speak in tongues that don't exist! 🐉✨
|
||||
|
||||
+24
-7
@@ -55,8 +55,24 @@ Injects tool templates from `templates/` into `dist/index.html`
|
||||
npm run build:templates
|
||||
```
|
||||
|
||||
### `build-alphabet-transforms.js`
|
||||
Regenerates hand-maintained symbol alphabet files from `data/alphabets/*.json` into `src/transformers/symbol/`.
|
||||
|
||||
```bash
|
||||
npm run build:alphabets
|
||||
```
|
||||
|
||||
Runs automatically before `build-transforms.js` (`npm run build:transforms`).
|
||||
|
||||
### `build-code-vendor.js`
|
||||
Bundles QR and barcode libraries into `js/vendor/` for the Codes tool (no CDN).
|
||||
|
||||
```bash
|
||||
npm run build:codes-vendor
|
||||
```
|
||||
|
||||
### `build-index.js`
|
||||
Generates transformer index
|
||||
Generates `src/transformers/index.js` (Node/test import index).
|
||||
|
||||
```bash
|
||||
npm run build:index
|
||||
@@ -66,12 +82,13 @@ npm run build:index
|
||||
|
||||
```bash
|
||||
npm run build # Runs all scripts in order:
|
||||
# 1. build:copy - Copy static files to dist/
|
||||
# 2. build:index - Generate transformer index
|
||||
# 3. build:transforms - Bundle transformers to dist/js/bundles/
|
||||
# 4. build:emoji - Generate emoji data to dist/js/data/
|
||||
# 5. build:tools - Inject tool scripts
|
||||
# 6. build:templates - Inject templates to dist/index.html
|
||||
# 1. build:tools - Inject tool scripts into index.template.html + toolRegistry
|
||||
# 2. build:codes-vendor - Bundle qrcode, JsBarcode, ZXing → js/vendor/
|
||||
# 3. build:copy - Copy static files to dist/
|
||||
# 4. build:index - Generate src/transformers/index.js
|
||||
# 5. build:transforms - build:alphabets + bundle transformers → dist/js/bundles/
|
||||
# 6. build:emoji - Generate emoji data to dist/js/data/
|
||||
# 7. build:templates - Inject templates → dist/index.html
|
||||
```
|
||||
|
||||
## Output Structure
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Generate symbol-alphabet transforms from data/alphabets/*.json
|
||||
* Output: src/transformers/symbol/<slug>.js (regenerated each build)
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const root = path.join(__dirname, '..');
|
||||
const dataDir = path.join(root, 'data', 'alphabets');
|
||||
const outDir = path.join(root, 'src', 'transformers', 'symbol');
|
||||
const GENERATED_HEADER = '// @generated from data/alphabets — do not edit by hand\n';
|
||||
|
||||
function slugToExportName(slug) {
|
||||
return slug.replace(/-/g, '_');
|
||||
}
|
||||
|
||||
function escapeJsString(s) {
|
||||
return s.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
|
||||
}
|
||||
|
||||
function escapeRegexCharClass(ch) {
|
||||
return ch.replace(/\\/g, '\\\\').replace(/\]/g, '\\]');
|
||||
}
|
||||
|
||||
function buildDetectorBlock(entry) {
|
||||
if (entry.detectorHint) {
|
||||
return `,
|
||||
detector: function(text) {
|
||||
return new RegExp('${escapeJsString(entry.detectorHint)}', 'u').test(text);
|
||||
}`;
|
||||
}
|
||||
const chars = [...new Set(Object.values(entry.map))];
|
||||
if (chars.length === 0) return '';
|
||||
const classContent = chars.map(ch => escapeRegexCharClass(ch)).join('');
|
||||
return `,
|
||||
detector: function(text) {
|
||||
return /[${classContent}]/u.test(text);
|
||||
}`;
|
||||
}
|
||||
|
||||
function buildMapObject(map) {
|
||||
const lines = [];
|
||||
for (const [key, value] of Object.entries(map)) {
|
||||
lines.push(` '${escapeJsString(key)}': '${escapeJsString(value)}'`);
|
||||
}
|
||||
return lines.join(',\n');
|
||||
}
|
||||
|
||||
function generateTransformFile(entry, slug) {
|
||||
const name = entry.name || slug;
|
||||
const priority = entry.priority != null ? entry.priority : 100;
|
||||
const description = entry.description ? `\n description: '${escapeJsString(entry.description)}',` : '';
|
||||
const mapBlock = buildMapObject(entry.map);
|
||||
|
||||
return `${GENERATED_HEADER}import BaseTransformer from '../BaseTransformer.js';
|
||||
|
||||
export default new BaseTransformer({
|
||||
name: '${escapeJsString(name)}',
|
||||
priority: ${priority},
|
||||
category: 'symbol',${description}
|
||||
map: {
|
||||
${mapBlock}
|
||||
},
|
||||
func: function(text) {
|
||||
return [...text].map(c => this.map[c] || this.map[c.toUpperCase()] || c).join('');
|
||||
},
|
||||
preview: function(text) {
|
||||
if (!text) return '[${escapeJsString(slug)}]';
|
||||
return this.func(text.slice(0, 6)) + (text.length > 6 ? '…' : '');
|
||||
}${buildDetectorBlock(entry)}
|
||||
});
|
||||
`;
|
||||
}
|
||||
|
||||
function validateEntry(entry, file) {
|
||||
if (!entry.name || !entry.map || typeof entry.map !== 'object') {
|
||||
throw new Error(`${file}: requires "name" and "map" object`);
|
||||
}
|
||||
const keys = Object.keys(entry.map);
|
||||
if (keys.length < 26) {
|
||||
console.warn(`⚠️ ${file}: map has only ${keys.length} entries`);
|
||||
}
|
||||
}
|
||||
|
||||
function loadUnicodeMaps() {
|
||||
const unicodeDir = path.join(root, 'src', 'transformers', 'unicode');
|
||||
const maps = [];
|
||||
if (!fs.existsSync(unicodeDir)) return maps;
|
||||
for (const file of fs.readdirSync(unicodeDir)) {
|
||||
if (!file.endsWith('.js')) continue;
|
||||
const content = fs.readFileSync(path.join(unicodeDir, file), 'utf8');
|
||||
const nameMatch = content.match(/name:\s*'([^']+)'/);
|
||||
const map = {};
|
||||
for (const match of content.matchAll(/'([^']+)':\s*'([^']+)'/g)) {
|
||||
map[match[1]] = match[2];
|
||||
}
|
||||
if (Object.keys(map).length >= 20) {
|
||||
maps.push({ name: nameMatch ? nameMatch[1] : file, map });
|
||||
}
|
||||
}
|
||||
return maps;
|
||||
}
|
||||
|
||||
function overlapScore(entryMap, existingMap) {
|
||||
let matched = 0;
|
||||
let total = 0;
|
||||
for (const [key, value] of Object.entries(entryMap)) {
|
||||
total++;
|
||||
const variants = [key, key.toLowerCase(), key.toUpperCase()];
|
||||
for (const variant of variants) {
|
||||
if (existingMap[variant] === value) {
|
||||
matched++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { matched, total };
|
||||
}
|
||||
|
||||
function checkOverlap(entry, file, unicodeMaps) {
|
||||
for (const existing of unicodeMaps) {
|
||||
const { matched, total } = overlapScore(entry.map, existing.map);
|
||||
if (total >= 20 && matched / total >= 0.9) {
|
||||
throw new Error(
|
||||
`${file}: ${matched}/${total} glyphs match existing Unicode transform "${existing.name}" — use that instead or pick distinct symbols`
|
||||
);
|
||||
}
|
||||
if (total >= 20 && matched / total >= 0.75) {
|
||||
console.warn(`⚠️ ${file}: ${matched}/${total} glyphs overlap "${existing.name}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
if (!fs.existsSync(dataDir)) {
|
||||
fs.mkdirSync(dataDir, { recursive: true });
|
||||
console.log('Created data/alphabets/ (add JSON files to generate symbol transforms)');
|
||||
return;
|
||||
}
|
||||
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
|
||||
// Remove previously generated symbol transforms
|
||||
for (const file of fs.readdirSync(outDir)) {
|
||||
if (!file.endsWith('.js')) continue;
|
||||
const full = path.join(outDir, file);
|
||||
const head = fs.readFileSync(full, 'utf8').slice(0, 80);
|
||||
if (head.includes('@generated')) {
|
||||
fs.unlinkSync(full);
|
||||
}
|
||||
}
|
||||
|
||||
const jsonFiles = fs.readdirSync(dataDir).filter(f => f.endsWith('.json')).sort();
|
||||
if (!jsonFiles.length) {
|
||||
console.log('No alphabet JSON files in data/alphabets/');
|
||||
return;
|
||||
}
|
||||
|
||||
let count = 0;
|
||||
const unicodeMaps = loadUnicodeMaps();
|
||||
for (const file of jsonFiles) {
|
||||
const slug = file.replace(/\.json$/, '');
|
||||
const raw = fs.readFileSync(path.join(dataDir, file), 'utf8');
|
||||
const entry = JSON.parse(raw);
|
||||
validateEntry(entry, file);
|
||||
checkOverlap(entry, file, unicodeMaps);
|
||||
const outPath = path.join(outDir, `${slug}.js`);
|
||||
fs.writeFileSync(outPath, generateTransformFile(entry, slug), 'utf8');
|
||||
console.log(`✅ Alphabet: ${slug} → symbol/${slug}.js`);
|
||||
count++;
|
||||
}
|
||||
|
||||
console.log(`\n✨ Generated ${count} symbol alphabet transform(s)`);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,50 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Bundle QR/barcode vendor libraries for the Codes tool (local copies, no CDN).
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const esbuild = require('esbuild');
|
||||
|
||||
const projectRoot = path.join(__dirname, '..');
|
||||
const vendorDir = path.join(projectRoot, 'js/vendor');
|
||||
|
||||
if (!fs.existsSync(vendorDir)) {
|
||||
fs.mkdirSync(vendorDir, { recursive: true });
|
||||
}
|
||||
|
||||
const bundles = [
|
||||
{
|
||||
entry: path.join(projectRoot, 'node_modules/qrcode/lib/browser.js'),
|
||||
outfile: path.join(vendorDir, 'qrcode.min.js'),
|
||||
globalName: 'QRCode'
|
||||
},
|
||||
{
|
||||
entry: path.join(projectRoot, 'node_modules/jsbarcode/bin/JsBarcode.js'),
|
||||
outfile: path.join(vendorDir, 'JsBarcode.min.js'),
|
||||
globalName: 'JsBarcode'
|
||||
}
|
||||
];
|
||||
|
||||
console.log('📦 Building Codes tool vendor bundles...\n');
|
||||
|
||||
bundles.forEach(function(spec) {
|
||||
esbuild.buildSync({
|
||||
entryPoints: [spec.entry],
|
||||
bundle: true,
|
||||
format: 'iife',
|
||||
globalName: spec.globalName,
|
||||
outfile: spec.outfile,
|
||||
minify: true,
|
||||
platform: 'browser'
|
||||
});
|
||||
const sizeKb = (fs.statSync(spec.outfile).size / 1024).toFixed(1);
|
||||
console.log('✅ ' + path.basename(spec.outfile) + ' (' + sizeKb + 'KB)');
|
||||
});
|
||||
|
||||
const zxingSrc = path.join(projectRoot, 'node_modules/@zxing/library/umd/index.min.js');
|
||||
const zxingDest = path.join(vendorDir, 'zxing.min.js');
|
||||
fs.copyFileSync(zxingSrc, zxingDest);
|
||||
console.log('✅ zxing.min.js (' + (fs.statSync(zxingDest).size / 1024).toFixed(1) + 'KB)');
|
||||
console.log('\n✨ Codes vendor bundles ready\n');
|
||||
@@ -95,14 +95,25 @@ 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();
|
||||
|
||||
|
||||
if (/^(const|let|var|function)\s/m.test(cleanContent)) {
|
||||
console.error(`❌ ${name}: top-level declarations break the bundle — wrap in export default (function(){ ... return new BaseTransformer(...); })();`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
output += `// ${name} (from ${filePath})\n`;
|
||||
output += `transforms['${name}'] = ${cleanContent}\n\n`;
|
||||
if (/^\(function\s*\(\)/.test(cleanContent)) {
|
||||
output += `transforms['${name}'] = ${cleanContent}\n\n`;
|
||||
} else 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) {
|
||||
|
||||
@@ -21,7 +21,9 @@ const templateFiles = [
|
||||
'tokenizer.html',
|
||||
'bijection.html',
|
||||
'splitter.html',
|
||||
'gibberish.html'
|
||||
'gibberish.html',
|
||||
'spellingalphabet.html',
|
||||
'codes.html'
|
||||
];
|
||||
|
||||
|
||||
@@ -52,21 +54,23 @@ if (!fs.existsSync(templatePath)) {
|
||||
}
|
||||
|
||||
let indexContent = fs.readFileSync(templatePath, 'utf8');
|
||||
indexContent = indexContent.replace(/\r\n/g, '\n');
|
||||
|
||||
// Find the tool-content-container
|
||||
const startMarker = '<div id="tool-content-container">';
|
||||
const endMarker = '</div>\n\n </div>\n\n <!-- Copy History Panel -->';
|
||||
const startMarker = '<div id="tool-content-container"';
|
||||
const endMarker = '</div>\n </main>';
|
||||
|
||||
const startIndex = indexContent.indexOf(startMarker);
|
||||
const endIndex = indexContent.indexOf(endMarker);
|
||||
const startTagEnd = indexContent.indexOf('>', startIndex);
|
||||
const endIndex = indexContent.indexOf(endMarker, startTagEnd);
|
||||
|
||||
if (startIndex === -1 || endIndex === -1) {
|
||||
if (startIndex === -1 || startTagEnd === -1 || endIndex === -1) {
|
||||
console.error('\n❌ Could not find tool content container markers');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Build the replacement content
|
||||
const before = indexContent.substring(0, startIndex + startMarker.length);
|
||||
const before = indexContent.substring(0, startTagEnd + 1);
|
||||
const after = indexContent.substring(endIndex);
|
||||
|
||||
const replacement = `
|
||||
|
||||
@@ -3,11 +3,12 @@
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
background-color: #25282c;
|
||||
color: #00FF41;
|
||||
background-color: var(--secondary-bg);
|
||||
color: var(--success-color);
|
||||
padding: 10px 20px;
|
||||
border-radius: 5px;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
|
||||
border: 1px solid var(--input-border);
|
||||
box-shadow: var(--panel-shadow-soft);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -16,7 +17,7 @@
|
||||
}
|
||||
|
||||
.copy-notification.error {
|
||||
color: #ff4141;
|
||||
color: #e53935;
|
||||
}
|
||||
|
||||
.copy-notification.fade-out {
|
||||
|
||||
+2677
-582
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "Alchemical Symbols",
|
||||
"priority": 100,
|
||||
"description": "Classical alchemical symbol alphabet",
|
||||
"detectorHint": "[🜂🜃🜄🜅🜆🜇🜈🜉]",
|
||||
"map": {
|
||||
"A": "🜂", "B": "🜃", "C": "🜄", "D": "🜅", "E": "🜆", "F": "🜇",
|
||||
"G": "🜈", "H": "🜉", "I": "🜊", "J": "🜋", "K": "🜌", "L": "🜍",
|
||||
"M": "🜎", "N": "🜏", "O": "🜐", "P": "🜑", "Q": "🜒", "R": "🜓",
|
||||
"S": "🜔", "T": "🜕", "U": "🜖", "V": "🜗", "W": "🜘", "X": "🜙",
|
||||
"Y": "🜚", "Z": "🜛"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "Celestial Alphabet",
|
||||
"priority": 100,
|
||||
"description": "Agrippa's celestial / angelic symbol alphabet",
|
||||
"detectorHint": "[☉☽☿♀♂♃♄♅♆♇☊☋]",
|
||||
"map": {
|
||||
"A": "☉", "B": "☽", "C": "☿", "D": "♀", "E": "♂", "F": "♃",
|
||||
"G": "♄", "H": "♅", "I": "♆", "J": "♇", "K": "☊", "L": "☋",
|
||||
"M": "⚳", "N": "⚴", "O": "⚵", "P": "⚶", "Q": "⚷", "R": "⚸",
|
||||
"S": "⚹", "T": "⚺", "U": "⚻", "V": "⚼", "W": "⚽", "X": "⚾",
|
||||
"Y": "⛢", "Z": "⛣"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "Daedric Alphabet",
|
||||
"priority": 100,
|
||||
"description": "Elder Scrolls inspired Daedric-style symbols",
|
||||
"detectorHint": "[ᚠᚡᚢᚣᚤᚥᚦᚧᚨᚩ]",
|
||||
"map": {
|
||||
"A": "ᚠ", "B": "ᚡ", "C": "ᚢ", "D": "ᚣ", "E": "ᚤ", "F": "ᚥ",
|
||||
"G": "ᚦ", "H": "ᚧ", "I": "ᚨ", "J": "ᚩ", "K": "ᚪ", "L": "ᚫ",
|
||||
"M": "ᚬ", "N": "ᚭ", "O": "ᚮ", "P": "ᚯ", "Q": "ᚰ", "R": "ᚱ",
|
||||
"S": "ᚲ", "T": "ᚳ", "U": "ᚴ", "V": "ᚵ", "W": "ᚶ", "X": "ᚷ",
|
||||
"Y": "ᚸ", "Z": "ᚹ"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "Dancing Men Cipher",
|
||||
"priority": 100,
|
||||
"description": "Sherlock Holmes stick-figure cipher (Unicode approximations)",
|
||||
"detectorHint": "[┣┫┳┻├┤┬┴╋╞╡╤╧]",
|
||||
"map": {
|
||||
"A": "┣", "B": "┫", "C": "┳", "D": "┻", "E": "├", "F": "┤",
|
||||
"G": "┬", "H": "┴", "I": "╋", "J": "╞", "K": "╡", "L": "╤",
|
||||
"M": "╧", "N": "╥", "O": "╨", "P": "╪", "Q": "╫", "R": "╬",
|
||||
"S": "╭", "T": "╮", "U": "╯", "V": "╰", "W": "▸", "X": "◂",
|
||||
"Y": "▴", "Z": "▾"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "Enochian Alphabet",
|
||||
"priority": 100,
|
||||
"description": "Enochian angelic script (Unicode approximations)",
|
||||
"detectorHint": "[ᛂᛃᛄᛅᛆᛇᛈᛉᛊᛋ]",
|
||||
"map": {
|
||||
"A": "ᛂ", "B": "ᛃ", "C": "ᛄ", "D": "ᛅ", "E": "ᛆ", "F": "ᛇ",
|
||||
"G": "ᛈ", "H": "ᛉ", "I": "ᛊ", "J": "ᛋ", "K": "ᛌ", "L": "ᛍ",
|
||||
"M": "ᛎ", "N": "ᛏ", "O": "ᛐ", "P": "ᛑ", "Q": "ᛒ", "R": "ᛓ",
|
||||
"S": "ᛔ", "T": "ᛕ", "U": "ᛖ", "V": "ᛗ", "W": "ᛘ", "X": "ᛙ",
|
||||
"Y": "ᛚ", "Z": "ᛛ"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "Malachim Alphabet",
|
||||
"priority": 100,
|
||||
"description": "Malachim / angel script symbol substitution",
|
||||
"detectorHint": "[✁✂✃✄✆✇✈✉✊✋✌✍✎✏]",
|
||||
"map": {
|
||||
"A": "✁", "B": "✂", "C": "✃", "D": "✄", "E": "✆", "F": "✇",
|
||||
"G": "✈", "H": "✉", "I": "✊", "J": "✋", "K": "✌", "L": "✍",
|
||||
"M": "✎", "N": "✏", "O": "✐", "P": "✑", "Q": "✒", "R": "✓",
|
||||
"S": "✔", "T": "✕", "U": "✖", "V": "✗", "W": "✘", "X": "✙",
|
||||
"Y": "✚", "Z": "✛"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "Mary Stuart Cipher",
|
||||
"priority": 100,
|
||||
"description": "Mary Queen of Scots nomenclator-style symbols",
|
||||
"detectorHint": "[❶❷❸❹❺❻❼❽❾]",
|
||||
"map": {
|
||||
"A": "❶", "B": "❷", "C": "❸", "D": "❹", "E": "❺", "F": "❻",
|
||||
"G": "❼", "H": "❽", "I": "❾", "J": "❿", "K": "➀", "L": "➁",
|
||||
"M": "➂", "N": "➃", "O": "➄", "P": "➅", "Q": "➆", "R": "➇",
|
||||
"S": "➈", "T": "➉", "U": "➊", "V": "➋", "W": "➌", "X": "➍",
|
||||
"Y": "➎", "Z": "➏"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "Moon Alphabet",
|
||||
"priority": 100,
|
||||
"description": "Moon phase / lunar symbol alphabet",
|
||||
"detectorHint": "[☾☽☊☋⚸⚹⚺⚻]",
|
||||
"map": {
|
||||
"A": "☾", "B": "☽", "C": "☊", "D": "☋", "E": "⚸", "F": "⚹",
|
||||
"G": "⚺", "H": "⚻", "I": "⚼", "J": "⚽", "K": "⚾", "L": "⛢",
|
||||
"M": "⛣", "N": "⛤", "O": "⛥", "P": "⛦", "Q": "⛧", "R": "⛨",
|
||||
"S": "⛩", "T": "⛪", "U": "⛫", "V": "⛬", "W": "⛭", "X": "⛮",
|
||||
"Y": "⛯", "Z": "⛰"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "Passing the River Alphabet",
|
||||
"priority": 100,
|
||||
"description": "Golden Dawn Passing the River tarot script",
|
||||
"detectorHint": "[♠♣♥♦♤♧♡♢]",
|
||||
"map": {
|
||||
"A": "♠", "B": "♣", "C": "♥", "D": "♦", "E": "♤", "F": "♧",
|
||||
"G": "♡", "H": "♢", "I": "♩", "J": "♪", "K": "♫", "L": "♬",
|
||||
"M": "♭", "N": "♮", "O": "♯", "P": "°", "Q": "±", "R": "×",
|
||||
"S": "÷", "T": "∓", "U": "∔", "V": "∕", "W": "∖", "X": "∗",
|
||||
"Y": "∘", "Z": "∙"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "Rosicrucian Cipher",
|
||||
"priority": 100,
|
||||
"description": "Rosicrucian / Golden Dawn symbol alphabet",
|
||||
"detectorHint": "[⛤⛥⛦⛧⛨⛩⛪⛫]",
|
||||
"map": {
|
||||
"A": "⛤", "B": "⛥", "C": "⛦", "D": "⛧", "E": "⛨", "F": "⛩",
|
||||
"G": "⛪", "H": "⛫", "I": "⛬", "J": "⛭", "K": "⛮", "L": "⛯",
|
||||
"M": "⛰", "N": "⛱", "O": "⛲", "P": "⛳", "Q": "⛴", "R": "⛵",
|
||||
"S": "⛶", "T": "⛷", "U": "⛸", "V": "⛹", "W": "⛺", "X": "⛻",
|
||||
"Y": "⛼", "Z": "⛽"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "Templars Cipher",
|
||||
"priority": 100,
|
||||
"description": "Templar / pigpen variant with dot markers",
|
||||
"detectorHint": "[◧◨◩◪◫◬◭◮◯]",
|
||||
"map": {
|
||||
"A": "◧", "B": "◨", "C": "◩", "D": "◪", "E": "◫", "F": "◬",
|
||||
"G": "◭", "H": "◮", "I": "◯", "J": "◰", "K": "◱", "L": "◲",
|
||||
"M": "◳", "N": "◴", "O": "◵", "P": "◶", "Q": "◷", "R": "◸",
|
||||
"S": "◹", "T": "◺", "U": "◻", "V": "◼", "W": "◽", "X": "◾",
|
||||
"Y": "◿", "Z": "⬒"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "Theban Alphabet",
|
||||
"priority": 100,
|
||||
"description": "Honoric / Theban witch alphabet (symbol substitution)",
|
||||
"detectorHint": "[∀∁∂∃∄∅∆∇∈∉∊∋∌∍∎∏∐∑−∓∔∕∖∗∘∙√∛∜∝]",
|
||||
"map": {
|
||||
"A": "∀", "B": "∁", "C": "∂", "D": "∃", "E": "∄", "F": "∅",
|
||||
"G": "∆", "H": "∇", "I": "∈", "J": "∉", "K": "∊", "L": "∋",
|
||||
"M": "∌", "N": "∍", "O": "∎", "P": "∏", "Q": "∐", "R": "∑",
|
||||
"S": "−", "T": "∓", "U": "∔", "V": "∕", "W": "∖", "X": "∗",
|
||||
"Y": "∘", "Z": "∙"
|
||||
}
|
||||
}
|
||||
+179
@@ -0,0 +1,179 @@
|
||||
# Themes
|
||||
|
||||
P4RS3LT0NGV3 uses a **token-based theme system**. Most UI reads CSS custom properties (`--accent-color`, `--button-bg`, etc.) from `body.theme-*` blocks, so new themes are mostly palette + typography work—not per-component rewrites.
|
||||
|
||||
## Built-in themes
|
||||
|
||||
| ID | Name | Notes |
|
||||
|----|------|--------|
|
||||
| `dark` | Dark | Default; blue accent |
|
||||
| `light` | Light | Light surfaces; blue accent |
|
||||
| `accessible` | Accessible | WCAG 2.1 AA high-contrast; system fonts; strong focus rings |
|
||||
| `bt6` | BT6 | Black + blood red + gold; mono titles |
|
||||
| `pliny` | Pliny | CRT green-on-black |
|
||||
| `cyberpunk` | Cyberpunk | Magenta + cyan neon |
|
||||
| `wildwest` | Wild West | Cream / sage / dusty rose (light `color-scheme`) |
|
||||
|
||||
Users pick a theme in **Advanced Settings** (utility dock → Settings tab). Press **`D`** to cycle themes. The choice is saved in `localStorage` under the key `theme`.
|
||||
|
||||
## Accessible theme
|
||||
|
||||
The **`accessible`** theme targets **[WCAG 2.1 Level AA](https://www.w3.org/TR/WCAG21/)** for color, contrast, focus, and motion.
|
||||
|
||||
| Requirement | Implementation |
|
||||
|-------------|------------------|
|
||||
| **1.4.3 Contrast (AA)** | `#000000` / `#595959` text on `#ffffff`; `#004080` accent; category active colors darkened for white labels |
|
||||
| **1.4.11 Non-text contrast** | `2px` borders at `#555555` on inputs, buttons, and cards |
|
||||
| **2.4.7 Focus visible** | `3px` `#004080` outline + `2px` offset on all interactive elements |
|
||||
| **1.4.12 Text spacing** | `1rem` base size, `1.5` line-height, system UI fonts |
|
||||
| **1.4.1 Use of color** | Active nav/tabs use underline + weight + border, not color alone |
|
||||
| **2.3.3 Animation** | Logo glitch and decorative card effects disabled |
|
||||
| **Transform cards** | Text **Options** / **Favorite** buttons; fixed preview contrast and row heights |
|
||||
| **Translation picker** | Text **Favorite** (and **Remove** on custom langs); star/× icons hidden |
|
||||
|
||||
Decorative themes (Cyberpunk, Pliny, etc.) are unchanged. For audits, test with the **Accessible** theme selected and keyboard-only navigation.
|
||||
|
||||
## How it works
|
||||
|
||||
1. **`js/utils/theme.js`** — Registry (`themes` array), `applyTheme()`, `cycleTheme()`, persistence.
|
||||
2. **`css/style.css`** — One `body.theme-<id> { … }` block per theme defining design tokens.
|
||||
3. **`css/themes-atmosphere.css`** — Optional “premium” overrides for custom themes (nav, transform cards, backgrounds, fonts). Imported at the top of `style.css`.
|
||||
4. **`index.template.html`** — Inline bootstrap script applies the saved theme **before** Vue mounts (avoids flash of wrong theme).
|
||||
|
||||
When a theme is applied, the body gets:
|
||||
|
||||
- `theme-<id>` — primary selector (e.g. `theme-bt6`)
|
||||
- `dark-theme` or `light-theme` — legacy aliases still used by a few rules
|
||||
|
||||
Copy an existing `body.theme-*` block as a starting point—`body.theme-dark` and `body.theme-light` are the simplest references; custom themes often follow the BT6 / Pliny pattern.
|
||||
|
||||
## Adding a new theme
|
||||
|
||||
### 1. Register in `js/utils/theme.js`
|
||||
|
||||
```javascript
|
||||
{ id: 'mytheme', name: 'My Theme', icon: 'fa-star' }
|
||||
```
|
||||
|
||||
- **`id`** — lowercase, no spaces; becomes `body.theme-mytheme`.
|
||||
- **`name`** — label in the dropdown.
|
||||
- **`icon`** — optional Font Awesome class (not shown in dropdown today, but kept for future use).
|
||||
|
||||
### 2. Add tokens in `css/style.css`
|
||||
|
||||
Add a new block (copy from `body.theme-dark` or an existing custom theme):
|
||||
|
||||
```css
|
||||
body.theme-mytheme {
|
||||
--main-bg-color: #…;
|
||||
--secondary-bg: #…;
|
||||
--nav-bg: #…;
|
||||
--utility-tab-bg: #…;
|
||||
--text-color: #…;
|
||||
--text-muted: #…;
|
||||
--accent-color: #…;
|
||||
--accent-color-rgb: R, G, B; /* comma-separated, no spaces */
|
||||
--accent-hover: #…;
|
||||
/* … accent-tint-*, surface-*, button-*, input-*, error-*, etc. */
|
||||
--glitch-color: #…;
|
||||
--glitch-color-rgb: R, G, B;
|
||||
--switch-border: #…;
|
||||
--switch-surface-glow: rgba(…);
|
||||
--switch-track-bg: #…;
|
||||
--switch-track-border: #…;
|
||||
--switch-track-checked-bg: #…;
|
||||
--switch-track-checked-glow: rgba(…);
|
||||
--switch-thumb-bg: #…;
|
||||
--switch-thumb-glow: rgba(…);
|
||||
--switch-thumb-checked-bg: #…;
|
||||
--switch-thumb-checked-glow: rgba(…);
|
||||
color-scheme: dark; /* or light for pale themes */
|
||||
}
|
||||
```
|
||||
|
||||
**Required tokens** — At minimum, match what `body.theme-dark` defines: surfaces, text, accent (+ `-rgb`), buttons, inputs, focus/error/success, tooltips, switch tokens, and legacy aliases (`--text-primary`, `--border-color`, etc.) at the bottom of the block.
|
||||
|
||||
**Light themes** — Set `color-scheme: light` and ensure `--text-on-accent` contrasts on `--accent-color`. Wild West is the reference for a light custom theme.
|
||||
|
||||
**Optional theme tokens** (used by atmosphere CSS):
|
||||
|
||||
- `--theme-display-font`, `--theme-ui-font`, `--theme-mono-font`
|
||||
- `--theme-secondary`, `--theme-secondary-rgb`
|
||||
- `--theme-radius` — corner radius (`0` for sharp, `2px`–`6px` for rounded)
|
||||
- Wild West also uses `--theme-cream`, `--theme-charcoal`, etc.
|
||||
|
||||
### 3. Optional atmosphere in `css/themes-atmosphere.css`
|
||||
|
||||
For a distinctive look beyond color swaps, add a section:
|
||||
|
||||
```css
|
||||
body.theme-mytheme .app-root::before { /* background texture / tint */ }
|
||||
body.theme-mytheme .transform-button { /* card chrome */ }
|
||||
body.theme-mytheme .app-nav .tab-buttons button.active { /* nav active state */ }
|
||||
```
|
||||
|
||||
If you add a custom theme here, also extend the shared selectors at the top of the file (`body[class*="theme-…"]`) so shell layering (`::before` / z-index) stays consistent.
|
||||
|
||||
**Style guidelines** (project convention):
|
||||
|
||||
- Use **flat colors** only—no CSS gradients unless explicitly requested.
|
||||
- Toggle switches (`.switch.neon`) pick up colors from `--switch-*` tokens automatically.
|
||||
- Avoid logo `::before` / `::after` decorations.
|
||||
|
||||
### 4. Fonts (if needed)
|
||||
|
||||
Google Fonts are imported at the top of `css/style.css`. Add families there, then reference them in your `--theme-*-font` tokens.
|
||||
|
||||
### 5. Select carets & dropdowns
|
||||
|
||||
Custom themes with non-default accents often need a `select` caret override. See existing blocks:
|
||||
|
||||
```css
|
||||
body.theme-mytheme select:not(.settings-theme-select) { background-image: url("data:…"); }
|
||||
body.theme-mytheme .settings-theme-select { … }
|
||||
```
|
||||
|
||||
Wild West required extra care so theme dropdown carets do not tile—use `background-repeat: no-repeat` and longhand `background-*` when overriding selects.
|
||||
|
||||
### 6. Build & test
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
npm test
|
||||
```
|
||||
|
||||
Open `dist/index.html` (or `npm start`) and check:
|
||||
|
||||
- [ ] Advanced Settings theme dropdown lists your theme
|
||||
- [ ] **`D`** cycles through it without errors
|
||||
- [ ] Nav, main content, utility dock (desktop)
|
||||
- [ ] Mobile: utility panel toggle in header; no overlapping FAB
|
||||
- [ ] Transform grid, toggles, inputs, notifications
|
||||
- [ ] Advanced Settings / OpenRouter model dropdown (if applicable)
|
||||
- [ ] Reload persists choice
|
||||
|
||||
No change to `index.template.html` is required unless you rename the bootstrap script flow—the dropdown is driven by `themeOptions` from Vue, which reads `ThemeUtils.getThemes()`.
|
||||
|
||||
## File checklist
|
||||
|
||||
| File | Action |
|
||||
|------|--------|
|
||||
| `js/utils/theme.js` | Add `{ id, name, icon }` |
|
||||
| `css/style.css` | Add `body.theme-<id> { … }` token block |
|
||||
| `css/themes-atmosphere.css` | Optional component/atmosphere overrides |
|
||||
| `css/style.css` `@import` | Already imports `themes-atmosphere.css` — no change |
|
||||
| `README.md` | Add theme to user-facing list (optional) |
|
||||
|
||||
## Updating an existing theme
|
||||
|
||||
1. Edit the `body.theme-<id>` token block in `css/style.css`.
|
||||
2. Adjust matching rules in `css/themes-atmosphere.css` if the theme has premium styling.
|
||||
3. Rebuild and spot-check the same checklist above.
|
||||
|
||||
Token changes propagate everywhere that uses `var(--…)`; you rarely need to touch individual tools or templates.
|
||||
|
||||
## Related docs
|
||||
|
||||
- **User-facing overview**: `README.md` → User Experience
|
||||
- **Project layout**: `CONTRIBUTING.md` → Project Structure
|
||||
- **UI patterns**: `docs/UI-COMPONENTS.md`
|
||||
+368
-273
@@ -3,7 +3,13 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Parseltongue 2.0 - LLM Payload Crafter</title>
|
||||
<title>Parseltongue 4.0 — Text Encoder, Decoder & Steganography Tool</title>
|
||||
<meta name="description" content="Free online text encoder & decoder with 222+ transforms: ciphers, Base64, Unicode styles, emoji steganography & QR codes. No install required — because sometimes you need to speak in tongues that don't exist.">
|
||||
<meta name="keywords" content="P4RS3LT0NGV3, Parseltongue, text encoder, text decoder, cipher tool, steganography, Unicode converter, Base64">
|
||||
<meta name="application-name" content="P4RS3LT0NGV3">
|
||||
<meta property="og:title" content="Parseltongue 4.0 — Text Encoder, Decoder & Steganography Tool">
|
||||
<meta property="og:description" content="222+ encodings, ciphers & steganography in your browser. Because sometimes you need to speak in tongues that don't exist.">
|
||||
<meta property="og:type" content="website">
|
||||
<link rel="icon" type="image/svg+xml" href="favicon.svg">
|
||||
<link rel="stylesheet" href="css/style.css">
|
||||
<link rel="stylesheet" href="css/notification.css">
|
||||
@@ -13,161 +19,185 @@
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app" class="container">
|
||||
<header>
|
||||
<div class="logo">
|
||||
<h1>🐉️︎︎︎︎︎︎︎️︎︎︎️︎︎︎️︎️️︎️️︎︎︎︎︎︎︎️︎︎︎️︎︎︎️︎️️︎️️︎︎︎︎︎︎︎️︎︎︎️︎︎︎️︎️️︎️️︎︎︎︎︎︎︎️︎︎︎️︎︎︎️︎️️︎️︎︎️️️️︎︎︎️️️️️︎︎︎️︎︎︎️️️︎️︎︎️️️️︎️︎︎︎️︎︎︎️︎︎️️︎️︎️︎︎️️️️︎️︎︎︎️︎︎︎️︎︎︎️︎️︎︎️️️︎️︎︎️︎︎︎️︎️︎️︎︎️️️︎︎️︎︎︎︎︎️︎️︎︎︎︎️︎︎️︎︎️️︎︎︎️︎︎︎️︎️︎️︎︎︎️︎︎︎︎️︎️️️︎︎︎️︎️️️︎︎︎️︎️️️︎︎️︎︎️️︎︎︎️︎︎️️️️︎️︎️︎️️︎︎️︎︎︎️︎️︎︎️️️︎︎️︎️︎️︎︎︎︎︎︎️︎︎️️︎︎︎️︎︎️︎︎️︎️︎︎️️️︎︎️︎️️︎︎️︎️️️️️︎︎︎︎️️️️️︎︎︎️︎️️︎️️︎︎︎︎︎︎︎️︎︎︎️︎︎︎️︎️️︎️️︎︎︎︎︎︎︎️︎︎︎️︎︎︎️︎️️︎️️︎︎︎︎︎︎︎️︎︎︎️︎︎︎️︎️️︎️️︎︎︎︎︎︎︎️︎︎︎️︎︎︎️︎️️︎️️︎︎︎︎︎︎︎️︎︎︎️︎︎︎️︎️️︎️️︎︎︎︎︎︎︎️︎︎︎️︎ P4RS3LT0NGV3</h1>
|
||||
<script>
|
||||
(function () {
|
||||
try {
|
||||
var saved = localStorage.getItem('theme') || 'dark';
|
||||
document.body.classList.add('theme-' + saved);
|
||||
document.body.classList.add(
|
||||
saved === 'light' || saved === 'accessible' ? 'light-theme' : 'dark-theme'
|
||||
);
|
||||
} catch (e) {
|
||||
document.body.classList.add('theme-dark', 'dark-theme');
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<div id="app" class="app-root">
|
||||
<header class="app-topbar">
|
||||
<div class="logo app-logo">
|
||||
<h1>🐉 P4RS3LT0NGV3</h1>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<div class="actions topbar-actions">
|
||||
<button
|
||||
@click="toggleCopyHistory"
|
||||
class="history-button"
|
||||
title="Show copy history"
|
||||
aria-label="Show copy history"
|
||||
type="button"
|
||||
class="topbar-utility-btn"
|
||||
@click="mobileUtilityOpen ? closeMobileUtility() : openMobileUtility()"
|
||||
:title="mobileUtilityOpen ? 'Close panels' : 'Open panels'"
|
||||
:aria-label="mobileUtilityOpen ? 'Close panels' : 'Open panels'"
|
||||
:aria-expanded="mobileUtilityOpen"
|
||||
>
|
||||
<i class="fas fa-history"></i>
|
||||
<i :class="mobileUtilityOpen ? 'fas fa-xmark' : 'fas fa-columns'"></i>
|
||||
</button>
|
||||
<button
|
||||
@click="toggleTheme"
|
||||
@keyup.d="toggleTheme"
|
||||
class="theme-button"
|
||||
title="Toggle dark mode (D)"
|
||||
aria-label="Toggle dark mode"
|
||||
>
|
||||
<i class="fas" :class="isDarkTheme ? 'fa-moon' : 'fa-sun'"></i>
|
||||
</button>
|
||||
<a
|
||||
href="https://github.com/elder-plinius/P4RS3LT0NGV3"
|
||||
target="_blank"
|
||||
class="github-button"
|
||||
<a
|
||||
href="https://github.com/elder-plinius/P4RS3LT0NGV3"
|
||||
target="_blank"
|
||||
class="github-button"
|
||||
title="View source on GitHub"
|
||||
aria-label="View source code on GitHub"
|
||||
>
|
||||
<i class="fab fa-github"></i>
|
||||
</a>
|
||||
<button
|
||||
@click="toggleUnicodePanel"
|
||||
class="history-button"
|
||||
title="Advanced Settings"
|
||||
aria-label="Advanced Settings"
|
||||
>
|
||||
<i class="fas fa-sliders-h"></i>
|
||||
</button>
|
||||
<button
|
||||
@click="toggleGlitchTokenPanel"
|
||||
class="history-button"
|
||||
title="Glitch Tokens"
|
||||
aria-label="Glitch Tokens"
|
||||
>
|
||||
<i class="fas fa-bug"></i>
|
||||
</button>
|
||||
<button
|
||||
@click="toggleEndSequencePanel"
|
||||
class="history-button"
|
||||
title="End sequences (delimiter strings)"
|
||||
aria-label="End sequences"
|
||||
>
|
||||
<i class="fas fa-stop"></i>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="tabs">
|
||||
<div class="tab-buttons" role="tablist" aria-label="Tools">
|
||||
<!-- Dynamically generated tab buttons from tool registry -->
|
||||
<button
|
||||
v-for="tool in registeredTools"
|
||||
v-if="!tool.hidden"
|
||||
:key="tool.id"
|
||||
:class="{ active: activeTab === tool.id }"
|
||||
@click="switchToTab(tool.id)"
|
||||
:title="tool.title"
|
||||
role="tab"
|
||||
:aria-selected="activeTab === tool.id"
|
||||
>
|
||||
<i :class="'fas ' + tool.icon"></i> {{ tool.name }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="tab-tool-select">
|
||||
<label for="mobile-tool-select">Selected Tool</label>
|
||||
<select
|
||||
id="mobile-tool-select"
|
||||
class="mobile-tool-dropdown"
|
||||
:value="activeTab"
|
||||
aria-label="Selected tool"
|
||||
@change="switchToTab($event.target.value)"
|
||||
>
|
||||
<template v-for="tool in registeredTools">
|
||||
<option
|
||||
v-if="!tool.hidden"
|
||||
:key="tool.id"
|
||||
:value="tool.id"
|
||||
>{{ tool.name }}</option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div id="tool-content-container">
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Copy History Panel -->
|
||||
<div class="app-sidebar copy-history-panel" :class="{ 'active': showCopyHistory }">
|
||||
<div class="app-sidebar-header copy-history-header">
|
||||
<h3><i class="fas fa-history"></i> Copy History</h3>
|
||||
<div class="header-actions">
|
||||
<button
|
||||
v-if="copyHistory.length > 0"
|
||||
@click.stop="clearCopyHistory"
|
||||
class="clear-history-button"
|
||||
title="Clear all history"
|
||||
>
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
<button class="close-button" @click="toggleCopyHistory" title="Close history">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="app-shell">
|
||||
<nav class="app-nav" role="tablist" aria-label="Tools">
|
||||
<div class="tab-buttons">
|
||||
<button
|
||||
v-for="tool in registeredTools"
|
||||
v-if="!tool.hidden"
|
||||
:key="tool.id"
|
||||
:class="{ active: activeTab === tool.id }"
|
||||
@click="switchToTab(tool.id)"
|
||||
:title="tool.title"
|
||||
role="tab"
|
||||
:aria-selected="activeTab === tool.id"
|
||||
>
|
||||
<i :class="'fas ' + tool.icon"></i>
|
||||
<span class="nav-label">{{ tool.name }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="app-sidebar-body copy-history-content">
|
||||
<div v-if="copyHistory.length === 0" class="no-history">
|
||||
<p>No copy history yet. Use the app features to auto-copy content.</p>
|
||||
</div>
|
||||
<div v-else class="history-items">
|
||||
<div v-for="(item, index) in copyHistory" :key="item.id || index" class="history-item">
|
||||
<div class="history-item-header">
|
||||
<span class="history-source">{{ item.source }}</span>
|
||||
<span class="history-time">{{ formatHistoryTime(item.timestamp) }}</span>
|
||||
</nav>
|
||||
|
||||
<main class="app-main">
|
||||
<div class="tab-tool-select">
|
||||
<label for="mobile-tool-select">Selected Tool</label>
|
||||
<select
|
||||
id="mobile-tool-select"
|
||||
class="mobile-tool-dropdown"
|
||||
:value="activeTab"
|
||||
aria-label="Selected tool"
|
||||
@change="switchToTab($event.target.value)"
|
||||
>
|
||||
<template v-for="tool in registeredTools">
|
||||
<option
|
||||
v-if="!tool.hidden"
|
||||
:key="tool.id"
|
||||
:value="tool.id"
|
||||
>{{ tool.name }}</option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div id="tool-content-container" class="tool-content-container">
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<div
|
||||
v-if="mobileUtilityOpen"
|
||||
class="utility-backdrop"
|
||||
@click="closeMobileUtility"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
|
||||
<aside
|
||||
class="app-utility"
|
||||
:class="{ 'utility-open': mobileUtilityOpen }"
|
||||
aria-label="Utilities"
|
||||
@click.stop
|
||||
>
|
||||
<div class="utility-tab-bar" role="tablist" aria-label="Utility panels" @click.stop>
|
||||
<button
|
||||
type="button"
|
||||
class="utility-tab-btn"
|
||||
:class="{ active: activeUtilityPanel === 'history' }"
|
||||
@click.stop="switchUtilityPanel('history')"
|
||||
role="tab"
|
||||
:aria-selected="activeUtilityPanel === 'history'"
|
||||
>
|
||||
Copy History
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="utility-tab-btn"
|
||||
:class="{ active: activeUtilityPanel === 'glitch' }"
|
||||
@click.stop="switchUtilityPanel('glitch')"
|
||||
role="tab"
|
||||
:aria-selected="activeUtilityPanel === 'glitch'"
|
||||
>
|
||||
Glitch Tokens
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="utility-tab-btn"
|
||||
:class="{ active: activeUtilityPanel === 'endsequences' }"
|
||||
@click.stop="switchUtilityPanel('endsequences')"
|
||||
role="tab"
|
||||
:aria-selected="activeUtilityPanel === 'endsequences'"
|
||||
>
|
||||
End Sequences
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="utility-tab-btn"
|
||||
:class="{ active: activeUtilityPanel === 'settings' }"
|
||||
@click.stop="switchUtilityPanel('settings')"
|
||||
role="tab"
|
||||
:aria-selected="activeUtilityPanel === 'settings'"
|
||||
>
|
||||
Settings
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="utility-panels">
|
||||
<div v-show="activeUtilityPanel === 'history'" class="utility-panel copy-history-panel">
|
||||
<div class="utility-panel-toolbar" v-if="copyHistory.length > 0">
|
||||
<button
|
||||
@click.stop="clearCopyHistory"
|
||||
class="clear-history-button"
|
||||
title="Clear all history"
|
||||
>
|
||||
<i class="fas fa-trash"></i> Clear all
|
||||
</button>
|
||||
</div>
|
||||
<div class="utility-panel-body copy-history-content">
|
||||
<div v-if="copyHistory.length === 0" class="no-history">
|
||||
<p>No copy history yet. Use the app features to auto-copy content.</p>
|
||||
</div>
|
||||
<div class="history-content">
|
||||
{{ item.content }}
|
||||
</div>
|
||||
<div class="history-actions">
|
||||
<button class="copy-again-button" @click="copyToClipboard(item.content)" title="Copy again">
|
||||
<i class="fas fa-copy"></i>
|
||||
</button>
|
||||
<button class="remove-history-button" @click.stop="removeFromCopyHistory(item.id)" title="Remove from history">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
<div v-else class="history-items">
|
||||
<div v-for="(item, index) in copyHistory" :key="item.id || index" class="history-item">
|
||||
<div class="history-item-header">
|
||||
<span class="history-source">{{ item.source }}</span>
|
||||
<span class="history-time">{{ formatHistoryTime(item.timestamp) }}</span>
|
||||
</div>
|
||||
<div class="history-content">
|
||||
{{ item.content }}
|
||||
</div>
|
||||
<div class="history-actions">
|
||||
<button class="copy-again-button" @click="copyToClipboard(item.content)" title="Copy again">
|
||||
<i class="fas fa-copy"></i>
|
||||
</button>
|
||||
<button class="remove-history-button" @click.stop="removeFromCopyHistory(item.id)" title="Remove from history">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Glitch Tokens Panel -->
|
||||
<div class="app-sidebar glitch-token-panel" :class="{ 'active': showGlitchTokenPanel }">
|
||||
<div class="app-sidebar-header glitch-token-header">
|
||||
<h3><i class="fas fa-bug"></i> Glitch Tokens</h3>
|
||||
<div class="header-actions">
|
||||
<button class="close-button" @click="toggleGlitchTokenPanel" title="Close glitch tokens">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="app-sidebar-body glitch-token-content">
|
||||
<div v-show="activeUtilityPanel === 'glitch'" class="utility-panel glitch-token-panel">
|
||||
<div class="utility-panel-body glitch-token-content">
|
||||
<!-- Filter Section -->
|
||||
<div class="glitch-token-filters">
|
||||
<div class="filter-group">
|
||||
@@ -251,147 +281,199 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- End sequences sidebar (delimiter / stop strings for research) -->
|
||||
<div class="app-sidebar end-sequence-panel" :class="{ active: showEndSequencePanel }">
|
||||
<div class="app-sidebar-header end-sequence-header">
|
||||
<h3><i class="fas fa-stop"></i> End sequences</h3>
|
||||
<div class="header-actions">
|
||||
<button class="close-button" type="button" @click="toggleEndSequencePanel" title="Close">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="app-sidebar-body end-sequence-content">
|
||||
<p class="end-sequence-lede">
|
||||
Strings sometimes used to probe delimiter and termination behavior. Copy into your payloads as needed for authorized testing.
|
||||
</p>
|
||||
<div
|
||||
v-for="cat in endSequenceCategories"
|
||||
:key="cat.title"
|
||||
class="endsequence-category"
|
||||
>
|
||||
<h4>{{ cat.title }}</h4>
|
||||
<div class="endsequence-items">
|
||||
<button
|
||||
v-for="(item, idx) in cat.items"
|
||||
:key="cat.title + '-' + idx"
|
||||
type="button"
|
||||
class="endsequence-item"
|
||||
@click="copyEndSequence(item.value)"
|
||||
:title="'Copy to clipboard'"
|
||||
:aria-label="'Copy ' + item.label"
|
||||
<div v-show="activeUtilityPanel === 'endsequences'" class="utility-panel end-sequence-panel">
|
||||
<div class="utility-panel-body end-sequence-content">
|
||||
<p class="end-sequence-lede">
|
||||
Strings sometimes used to probe delimiter and termination behavior. Copy into your payloads as needed for authorized testing.
|
||||
</p>
|
||||
<div
|
||||
v-for="cat in endSequenceCategories"
|
||||
:key="cat.title"
|
||||
class="endsequence-category"
|
||||
>
|
||||
<code class="endsequence-label">{{ item.label }}</code>
|
||||
<span class="endsequence-copy-affordance" aria-hidden="true">
|
||||
<i class="fas fa-copy"></i>
|
||||
</span>
|
||||
</button>
|
||||
<h4>{{ cat.title }}</h4>
|
||||
<div class="endsequence-items">
|
||||
<button
|
||||
v-for="(item, idx) in cat.items"
|
||||
:key="cat.title + '-' + idx"
|
||||
type="button"
|
||||
class="endsequence-item"
|
||||
@click="copyEndSequence(item.value)"
|
||||
:title="'Copy to clipboard'"
|
||||
:aria-label="'Copy ' + item.label"
|
||||
>
|
||||
<code class="endsequence-label">{{ item.label }}</code>
|
||||
<span class="endsequence-copy-affordance" aria-hidden="true">
|
||||
<i class="fas fa-copy"></i>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-show="activeUtilityPanel === 'settings'" id="unicode-options-panel" class="utility-panel unicode-options-panel">
|
||||
<div class="utility-panel-body unicode-panel-content">
|
||||
<div class="settings-section theme-settings-section">
|
||||
<h4><i class="fas fa-palette"></i> Theme</h4>
|
||||
<small class="settings-hint">App appearance. Press <kbd>D</kbd> to cycle themes.</small>
|
||||
<label class="settings-theme-field">
|
||||
Active theme
|
||||
<select
|
||||
class="settings-theme-select"
|
||||
:value="activeTheme"
|
||||
@change="setTheme($event.target.value)"
|
||||
aria-label="Select theme"
|
||||
>
|
||||
<option
|
||||
v-for="theme in themeOptions"
|
||||
:key="theme.id"
|
||||
:value="theme.id"
|
||||
>
|
||||
{{ theme.name }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<hr class="settings-divider" />
|
||||
<div class="settings-section api-key-section">
|
||||
<h4><i class="fas fa-key"></i> OpenRouter API Key</h4>
|
||||
<small class="settings-hint">Required for Translation, PromptCraft, and Anti-Classifier. Stored locally in your browser only.</small>
|
||||
<div class="api-key-input-row">
|
||||
<input
|
||||
:type="showApiKey ? 'text' : 'password'"
|
||||
v-model="openrouterApiKey"
|
||||
placeholder="sk-or-..."
|
||||
class="api-key-input"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
/>
|
||||
<button class="api-key-toggle" @click="showApiKey = !showApiKey" :title="showApiKey ? 'Hide key' : 'Show key'">
|
||||
<i :class="showApiKey ? 'fas fa-eye-slash' : 'fas fa-eye'"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="api-key-actions">
|
||||
<button class="api-key-save" @click="saveApiKey" :disabled="!openrouterApiKey">
|
||||
<i class="fas fa-save"></i> Save Key
|
||||
</button>
|
||||
<button class="api-key-clear" @click="clearApiKey" v-if="openrouterApiKey">
|
||||
<i class="fas fa-trash"></i> Clear
|
||||
</button>
|
||||
<small v-if="apiKeySaved" class="apply-status">Saved</small>
|
||||
</div>
|
||||
</div>
|
||||
<hr class="settings-divider" />
|
||||
<div class="settings-section openrouter-models-section">
|
||||
<h4><i class="fas fa-robot"></i> AI Models</h4>
|
||||
<small class="settings-hint">Choose which models appear in tool dropdowns. Disabled models stay hidden unless currently selected.</small>
|
||||
<div class="openrouter-models-toolbar">
|
||||
<input
|
||||
type="search"
|
||||
v-model="openRouterModelsFilterQuery"
|
||||
class="openrouter-models-search"
|
||||
placeholder="Search models…"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<button type="button" class="openrouter-models-action" @click="enableAllOpenRouterModels">Show all</button>
|
||||
<button type="button" class="openrouter-models-action" @click="showFreeOpenRouterModelsOnly">Free only</button>
|
||||
<button type="button" class="openrouter-models-action" @click="refreshOpenRouterModels(true)" :disabled="openRouterModelsLoading">
|
||||
<i class="fas" :class="openRouterModelsLoading ? 'fa-spinner fa-spin' : 'fa-sync-alt'"></i>
|
||||
</button>
|
||||
</div>
|
||||
<small class="openrouter-models-summary">
|
||||
{{ openRouterModels.length }} shown in dropdowns · {{ openRouterModelsCatalog.length }} loaded
|
||||
<span v-if="openRouterModelsDisabled.length"> · {{ openRouterModelsDisabled.length }} hidden</span>
|
||||
</small>
|
||||
<small v-if="openRouterModelsError" class="openrouter-model-hint openrouter-model-hint-error">{{ openRouterModelsError }}</small>
|
||||
<div class="openrouter-models-list" role="group" aria-label="Model visibility">
|
||||
<label
|
||||
v-for="model in filteredOpenRouterModelsCatalog"
|
||||
:key="model.id"
|
||||
class="openrouter-model-toggle-row"
|
||||
:class="{ 'is-virtual-router': model.virtual }"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="isOpenRouterModelEnabled(model.id)"
|
||||
@change="toggleOpenRouterModelEnabled(model.id)"
|
||||
/>
|
||||
<span class="openrouter-model-toggle-text">
|
||||
<span class="openrouter-model-toggle-name">{{ formatOpenRouterModelLabel(model) }}</span>
|
||||
<small v-if="model.summary" class="openrouter-model-toggle-summary">{{ model.summary }}</small>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<hr class="settings-divider" />
|
||||
<h4><i class="fas fa-user-secret"></i> Emoji Steganography</h4>
|
||||
<small class="settings-hint">Global encoding options for the Steganography tool (emoji carrier bit patterns). Not used by Unicode style transforms.</small>
|
||||
<div class="options-grid steg-adv-panel">
|
||||
<label>
|
||||
Initial Presentation
|
||||
<select class="steg-initial-presentation">
|
||||
<option value="emoji">Emoji (VS16)</option>
|
||||
<option value="text">Text (VS15)</option>
|
||||
<option value="none">None</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Bit-0 Selector
|
||||
<select class="steg-vs-zero">
|
||||
<option value="\ufe0e">VS15 (\ufe0e)</option>
|
||||
<option value="\ufe0f">VS16 (\ufe0f)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Bit-1 Selector
|
||||
<select class="steg-vs-one">
|
||||
<option value="\ufe0f">VS16 (\ufe0f)</option>
|
||||
<option value="\ufe0e">VS15 (\ufe0e)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Inter-bit Zero-Width
|
||||
<select class="steg-inter-zw">
|
||||
<option value="">None</option>
|
||||
<option value="\u200C">ZWNJ (\u200C)</option>
|
||||
<option value="\u200D">ZWJ (\u200D)</option>
|
||||
<option value="\u200B">ZWSP (\u200B)</option>
|
||||
<option value="\ufeff">BOM (\ufeff)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Inter-bit Every N bits
|
||||
<input class="steg-inter-every" type="number" min="1" max="8" value="1" />
|
||||
</label>
|
||||
<label>
|
||||
Bit Order
|
||||
<select class="steg-bit-order">
|
||||
<option value="msb">MSB First</option>
|
||||
<option value="lsb">LSB First</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Trailing Zero-Width
|
||||
<select class="steg-trailing-zw">
|
||||
<option value="\u200B">ZWSP (\u200B)</option>
|
||||
<option value="\u200C">ZWNJ (\u200C)</option>
|
||||
<option value="\u200D">ZWJ (\u200D)</option>
|
||||
<option value="\ufeff">BOM (\ufeff)</option>
|
||||
<option value="">None</option>
|
||||
</select>
|
||||
</label>
|
||||
<div style="display:flex; align-items:center; gap:10px;">
|
||||
<button class="apply-steg-options" :class="{ applied: unicodeApplyFlash }" :disabled="unicodeApplyBusy" @click="applyUnicodeOptions" title="These options affect Unicode-based steganography encoding/decoding.">Apply</button>
|
||||
<small v-if="unicodeApplyFlash" class="apply-status">Applied</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Advanced Settings Panel (inside app so Vue bindings work) -->
|
||||
<div id="unicode-options-panel" class="app-sidebar unicode-options-panel">
|
||||
<div class="app-sidebar-header unicode-panel-header">
|
||||
<h3><i class="fas fa-sliders-h"></i> Advanced Settings</h3>
|
||||
<button class="close-button" title="Close Advanced Settings"><i class="fas fa-times"></i></button>
|
||||
</div>
|
||||
<div class="app-sidebar-body unicode-panel-content">
|
||||
<!-- OpenRouter API Key -->
|
||||
<div class="settings-section api-key-section">
|
||||
<h4><i class="fas fa-key"></i> OpenRouter API Key</h4>
|
||||
<small class="settings-hint">Required for Translation, PromptCraft, and Anti-Classifier. Stored locally in your browser only.</small>
|
||||
<div class="api-key-input-row">
|
||||
<input
|
||||
:type="showApiKey ? 'text' : 'password'"
|
||||
v-model="openrouterApiKey"
|
||||
placeholder="sk-or-..."
|
||||
class="api-key-input"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
/>
|
||||
<button class="api-key-toggle" @click="showApiKey = !showApiKey" :title="showApiKey ? 'Hide key' : 'Show key'">
|
||||
<i :class="showApiKey ? 'fas fa-eye-slash' : 'fas fa-eye'"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="api-key-actions">
|
||||
<button class="api-key-save" @click="saveApiKey" :disabled="!openrouterApiKey">
|
||||
<i class="fas fa-save"></i> Save Key
|
||||
</button>
|
||||
<button class="api-key-clear" @click="clearApiKey" v-if="openrouterApiKey">
|
||||
<i class="fas fa-trash"></i> Clear
|
||||
</button>
|
||||
<small v-if="apiKeySaved" class="apply-status">Saved</small>
|
||||
</div>
|
||||
</div>
|
||||
<hr class="settings-divider" />
|
||||
<!-- Steganography Options -->
|
||||
<h4><i class="fas fa-user-secret"></i> Steganography Options</h4>
|
||||
</div>
|
||||
<div class="app-sidebar-body unicode-panel-content options-grid steg-adv-panel">
|
||||
<label>
|
||||
Initial Presentation
|
||||
<select class="steg-initial-presentation">
|
||||
<option value="emoji">Emoji (VS16)</option>
|
||||
<option value="text">Text (VS15)</option>
|
||||
<option value="none">None</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Bit-0 Selector
|
||||
<select class="steg-vs-zero">
|
||||
<option value="\ufe0e">VS15 (\ufe0e)</option>
|
||||
<option value="\ufe0f">VS16 (\ufe0f)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Bit-1 Selector
|
||||
<select class="steg-vs-one">
|
||||
<option value="\ufe0f">VS16 (\ufe0f)</option>
|
||||
<option value="\ufe0e">VS15 (\ufe0e)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Inter-bit Zero-Width
|
||||
<select class="steg-inter-zw">
|
||||
<option value="">None</option>
|
||||
<option value="\u200C">ZWNJ (\u200C)</option>
|
||||
<option value="\u200D">ZWJ (\u200D)</option>
|
||||
<option value="\u200B">ZWSP (\u200B)</option>
|
||||
<option value="\ufeff">BOM (\ufeff)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Inter-bit Every N bits
|
||||
<input class="steg-inter-every" type="number" min="1" max="8" value="1" />
|
||||
</label>
|
||||
<label>
|
||||
Bit Order
|
||||
<select class="steg-bit-order">
|
||||
<option value="msb">MSB First</option>
|
||||
<option value="lsb">LSB First</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Trailing Zero-Width
|
||||
<select class="steg-trailing-zw">
|
||||
<option value="\u200B">ZWSP (\u200B)</option>
|
||||
<option value="\u200C">ZWNJ (\u200C)</option>
|
||||
<option value="\u200D">ZWJ (\u200D)</option>
|
||||
<option value="\ufeff">BOM (\ufeff)</option>
|
||||
<option value="">None</option>
|
||||
</select>
|
||||
</label>
|
||||
<div style="display:flex; align-items:center; gap:10px;">
|
||||
<button class="apply-steg-options" :class="{ applied: unicodeApplyFlash }" :disabled="unicodeApplyBusy" @click="applyUnicodeOptions" title="These options affect Unicode-based steganography encoding/decoding.">Apply</button>
|
||||
<small v-if="unicodeApplyFlash" class="apply-status">Applied</small>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -404,6 +486,8 @@
|
||||
|
||||
<!-- Generated bundles -->
|
||||
<script src="js/bundles/transforms-bundle.js"></script>
|
||||
<script src="js/core/spellingAlphabetTransform.js"></script>
|
||||
<script src="js/core/customSpellingAlphabets.js"></script>
|
||||
|
||||
<!-- Glitch Tokens Data -->
|
||||
<script src="js/data/glitchTokens.js"></script>
|
||||
@@ -421,29 +505,40 @@
|
||||
<script src="js/utils/history.js"></script>
|
||||
<script src="js/utils/clipboard.js"></script>
|
||||
<script src="js/utils/theme.js"></script>
|
||||
<script src="js/utils/openrouterModels.js"></script>
|
||||
<script src="js/utils/routing.js"></script>
|
||||
<script src="js/utils/emoji.js"></script>
|
||||
<script src="js/utils/ean13.js"></script>
|
||||
|
||||
<!-- Core modules (feature libraries) -->
|
||||
<script src="js/core/steganography.js"></script>
|
||||
<script src="js/core/transformOptions.js"></script>
|
||||
<script src="js/core/decoder.js"></script>
|
||||
<script src="js/core/lexemeAnalysis.js"></script>
|
||||
<script src="js/core/transformSelect.js"></script>
|
||||
|
||||
<!-- Load Tool System -->
|
||||
<script src="js/tools/Tool.js"></script>
|
||||
<script src="js/tools/AntiClassifierTool.js"></script>
|
||||
<script src="js/tools/BijectionTool.js"></script>
|
||||
<script src="js/tools/CodesTool.js"></script>
|
||||
<script src="js/tools/DecodeTool.js"></script>
|
||||
<script src="js/tools/EmojiTool.js"></script>
|
||||
<script src="js/tools/GibberishTool.js"></script>
|
||||
<script src="js/tools/MutationTool.js"></script>
|
||||
<script src="js/tools/PromptCraftTool.js"></script>
|
||||
<script src="js/tools/SpellingAlphabetTool.js"></script>
|
||||
<script src="js/tools/SplitterTool.js"></script>
|
||||
<script src="js/tools/TokenadeTool.js"></script>
|
||||
<script src="js/tools/TokenizerTool.js"></script>
|
||||
<script src="js/tools/TransformTool.js"></script>
|
||||
<script src="js/tools/TranslateTool.js"></script>
|
||||
<script src="js/core/toolRegistry.js"></script>
|
||||
|
||||
<!-- Codes tool vendor libraries (bundled locally at build time) -->
|
||||
<script src="js/vendor/qrcode.min.js"></script>
|
||||
<script src="js/vendor/JsBarcode.min.js"></script>
|
||||
<script src="js/vendor/zxing.min.js"></script>
|
||||
|
||||
<script src="js/app.js"></script>
|
||||
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
const baseData = {
|
||||
isDarkTheme: true,
|
||||
activeTheme: 'dark',
|
||||
themeOptions: (window.ThemeUtils && window.ThemeUtils.getThemes)
|
||||
? window.ThemeUtils.getThemes()
|
||||
: [{ id: 'dark', name: 'Dark' }, { id: 'light', name: 'Light' }],
|
||||
activeTab: 'transforms',
|
||||
registeredTools: [],
|
||||
universalDecodeInput: '',
|
||||
@@ -14,6 +17,9 @@ const baseData = {
|
||||
copyHistory: [],
|
||||
maxHistoryItems: window.CONFIG.MAX_HISTORY_ITEMS,
|
||||
showCopyHistory: false,
|
||||
activeUtilityPanel: 'history',
|
||||
mobileUtilityOpen: false,
|
||||
utilityPanelInteractionGuard: false,
|
||||
showUnicodePanel: false,
|
||||
unicodeApplyBusy: false,
|
||||
unicodeApplyFlash: false,
|
||||
@@ -33,7 +39,20 @@ const baseData = {
|
||||
allGlitchTokens: [],
|
||||
openrouterApiKey: localStorage.getItem('openrouter-api-key') || '',
|
||||
showApiKey: false,
|
||||
apiKeySaved: false
|
||||
apiKeySaved: false,
|
||||
openRouterModels: (window.OpenRouterModels && window.OpenRouterModels.getStaticFallback)
|
||||
? window.OpenRouterModels.getStaticFallback()
|
||||
: [],
|
||||
openRouterModelsCatalog: (window.OpenRouterModels && window.OpenRouterModels.getStaticFallback)
|
||||
? window.OpenRouterModels.getStaticFallback()
|
||||
: [],
|
||||
openRouterModelsDisabled: (window.OpenRouterModels && window.OpenRouterModels.loadDisabledIds)
|
||||
? window.OpenRouterModels.loadDisabledIds()
|
||||
: [],
|
||||
openRouterModelsFilterQuery: '',
|
||||
openRouterModelsLoading: false,
|
||||
openRouterModelsError: '',
|
||||
openRouterModelsKeyInfo: null
|
||||
};
|
||||
|
||||
const toolData = (window.toolRegistry && typeof window.toolRegistry.mergeVueData === 'function')
|
||||
@@ -45,21 +64,107 @@ const toolMethods = (window.toolRegistry && typeof window.toolRegistry.mergeVueM
|
||||
? window.toolRegistry.mergeVueMethods()
|
||||
: {};
|
||||
|
||||
Vue.component('openrouter-model-select', {
|
||||
props: {
|
||||
value: { type: String, default: '' },
|
||||
label: { type: String, default: 'Model' },
|
||||
showRefresh: { type: Boolean, default: true }
|
||||
},
|
||||
computed: {
|
||||
models: function() {
|
||||
return this.$root.openRouterModels || [];
|
||||
},
|
||||
loading: function() {
|
||||
return this.$root.openRouterModelsLoading;
|
||||
},
|
||||
error: function() {
|
||||
return this.$root.openRouterModelsError;
|
||||
},
|
||||
keyInfo: function() {
|
||||
return this.$root.openRouterModelsKeyInfo;
|
||||
},
|
||||
hasApiKey: function() {
|
||||
return this.$root.getOpenRouterApiKey ? !!this.$root.getOpenRouterApiKey() : false;
|
||||
},
|
||||
routerHint: function() {
|
||||
if (!this.value || !window.OpenRouterModels || !window.OpenRouterModels.getRouterHint) {
|
||||
return '';
|
||||
}
|
||||
return window.OpenRouterModels.getRouterHint(this.value);
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
onChange: function(event) {
|
||||
this.$emit('input', event.target.value);
|
||||
},
|
||||
refresh: function() {
|
||||
if (this.$root.refreshOpenRouterModels) {
|
||||
this.$root.refreshOpenRouterModels(true);
|
||||
}
|
||||
},
|
||||
formatLabel: function(model) {
|
||||
return this.$root.formatOpenRouterModelLabel
|
||||
? this.$root.formatOpenRouterModelLabel(model)
|
||||
: (model && model.name) || '';
|
||||
}
|
||||
},
|
||||
template:
|
||||
'<label class="openrouter-model-picker">' +
|
||||
'<span class="openrouter-model-label">{{ label }}</span>' +
|
||||
'<div class="openrouter-model-row">' +
|
||||
'<select ' +
|
||||
'class="openrouter-model-select" ' +
|
||||
':value="value" ' +
|
||||
'@change="onChange" ' +
|
||||
':disabled="loading && !models.length"' +
|
||||
'>' +
|
||||
'<option v-if="loading && !models.length" disabled value="">Loading models…</option>' +
|
||||
'<option v-for="m in models" :key="m.id" :value="m.id">{{ formatLabel(m) }}</option>' +
|
||||
'</select>' +
|
||||
'<button ' +
|
||||
'v-if="showRefresh" ' +
|
||||
'type="button" ' +
|
||||
'class="openrouter-model-refresh" ' +
|
||||
'@click="refresh" ' +
|
||||
':disabled="loading" ' +
|
||||
'title="Refresh model list from OpenRouter"' +
|
||||
'aria-label="Refresh model list"' +
|
||||
'>' +
|
||||
'<i class="fas" :class="loading ? \'fa-spinner fa-spin\' : \'fa-sync-alt\'"></i>' +
|
||||
'</button>' +
|
||||
'</div>' +
|
||||
'<small v-if="error" class="openrouter-model-hint openrouter-model-hint-error">{{ error }}</small>' +
|
||||
'<small v-else-if="routerHint" class="openrouter-model-hint openrouter-model-hint-router">{{ routerHint }}</small>' +
|
||||
'<small v-else-if="!hasApiKey" class="openrouter-model-hint">Add an OpenRouter key in Settings to load models for your account.</small>' +
|
||||
'<small v-else-if="keyInfo && keyInfo.is_free_tier" class="openrouter-model-hint">Free tier account — models marked · free need no credits.</small>' +
|
||||
'<small v-else class="openrouter-model-hint">Curate visible models in Settings → AI Models.</small>' +
|
||||
'</label>'
|
||||
});
|
||||
|
||||
window.app = new Vue({
|
||||
el: '#app',
|
||||
data: mergedData,
|
||||
computed: {
|
||||
filteredOpenRouterModelsCatalog: function() {
|
||||
var query = (this.openRouterModelsFilterQuery || '').trim().toLowerCase();
|
||||
var catalog = this.openRouterModelsCatalog || [];
|
||||
if (!query) return catalog;
|
||||
return catalog.filter(function(model) {
|
||||
var haystack = [
|
||||
model.id,
|
||||
model.name,
|
||||
model.provider,
|
||||
model.summary
|
||||
].filter(Boolean).join(' ').toLowerCase();
|
||||
return haystack.indexOf(query) !== -1;
|
||||
});
|
||||
}
|
||||
},
|
||||
methods: Object.assign({}, toolMethods || {}, {
|
||||
toggleUnicodePanel(event) {
|
||||
if (this.unicodePanelToggleLock) return;
|
||||
this.unicodePanelToggleLock = true;
|
||||
|
||||
this.showUnicodePanel = !this.showUnicodePanel;
|
||||
const panel = document.getElementById('unicode-options-panel');
|
||||
if (panel) {
|
||||
if (this.showUnicodePanel) panel.classList.add('active');
|
||||
else panel.classList.remove('active');
|
||||
}
|
||||
|
||||
this.switchUtilityPanel('settings');
|
||||
setTimeout(() => {
|
||||
this.unicodePanelToggleLock = false;
|
||||
}, 300);
|
||||
@@ -137,7 +242,59 @@ window.app = new Vue({
|
||||
setTimeout(()=>section && section.classList.remove('shake-once','randomizer-glow'), 600);
|
||||
} catch(_) {}
|
||||
},
|
||||
switchToTab(tabName) {
|
||||
getValidToolIds() {
|
||||
if (window.toolRegistry && typeof window.toolRegistry.getAll === 'function') {
|
||||
return window.toolRegistry.getAll().map(function(tool) { return tool.id; });
|
||||
}
|
||||
return [];
|
||||
},
|
||||
|
||||
getRouteSubState(tabName) {
|
||||
if (tabName === 'codes' && this.codesMode === 'decode') {
|
||||
return 'decode';
|
||||
}
|
||||
return '';
|
||||
},
|
||||
|
||||
applyRouteSubState(route) {
|
||||
if (!route || !route.sub) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (route.tab === 'codes' && (route.sub === 'generate' || route.sub === 'decode')) {
|
||||
this.codesMode = route.sub;
|
||||
}
|
||||
},
|
||||
|
||||
applyRouteFromHash() {
|
||||
if (!window.TabRouting) {
|
||||
return;
|
||||
}
|
||||
|
||||
var route = window.TabRouting.parse();
|
||||
var validIds = this.getValidToolIds();
|
||||
var defaultTab = 'transforms';
|
||||
|
||||
if (!route || !route.tab) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!validIds.includes(route.tab)) {
|
||||
this.switchToTab(defaultTab, { fromRoute: true, updateUrl: true, replaceUrl: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (route.tab !== this.activeTab) {
|
||||
this.switchToTab(route.tab, { fromRoute: true, updateUrl: false, route: route });
|
||||
return;
|
||||
}
|
||||
|
||||
this.applyRouteSubState(route);
|
||||
},
|
||||
|
||||
switchToTab(tabName, options) {
|
||||
options = options || {};
|
||||
|
||||
if (this.activeTab && window.toolRegistry) {
|
||||
window.toolRegistry.deactivateTool(this.activeTab, this);
|
||||
}
|
||||
@@ -149,36 +306,84 @@ window.app = new Vue({
|
||||
if (window.toolRegistry) {
|
||||
window.toolRegistry.activateTool(tabName, this);
|
||||
}
|
||||
|
||||
if (options.fromRoute && options.route) {
|
||||
this.applyRouteSubState(options.route);
|
||||
}
|
||||
|
||||
if (options.updateUrl !== false && !options.fromRoute && window.TabRouting) {
|
||||
window.TabRouting.setHash(tabName, this.getRouteSubState(tabName), !!options.replaceUrl);
|
||||
}
|
||||
},
|
||||
|
||||
toggleTheme() {
|
||||
this.isDarkTheme = window.ThemeUtils.toggleTheme(this.isDarkTheme);
|
||||
setTheme(themeId) {
|
||||
if (!window.ThemeUtils) return;
|
||||
this.activeTheme = window.ThemeUtils.applyTheme(themeId);
|
||||
},
|
||||
|
||||
cycleTheme() {
|
||||
if (!window.ThemeUtils) return;
|
||||
this.activeTheme = window.ThemeUtils.cycleTheme(this.activeTheme);
|
||||
},
|
||||
|
||||
toggleCopyHistory() {
|
||||
this.showCopyHistory = !this.showCopyHistory;
|
||||
|
||||
if (this.showCopyHistory && this.copyHistory.length > 0) {
|
||||
this.$nextTick(() => {
|
||||
const firstCopyButton = document.querySelector('.copy-again-button');
|
||||
this.switchUtilityPanel('history');
|
||||
},
|
||||
|
||||
toggleGlitchTokenPanel() {
|
||||
this.switchUtilityPanel('glitch');
|
||||
},
|
||||
|
||||
toggleEndSequencePanel() {
|
||||
this.switchUtilityPanel('endsequences');
|
||||
},
|
||||
|
||||
markUtilityPanelInteraction() {
|
||||
this.utilityPanelInteractionGuard = true;
|
||||
var self = this;
|
||||
setTimeout(function() {
|
||||
self.utilityPanelInteractionGuard = false;
|
||||
}, 400);
|
||||
},
|
||||
|
||||
switchUtilityPanel(panelId) {
|
||||
var isMobile = window.matchMedia && window.matchMedia('(max-width: 768px)').matches;
|
||||
|
||||
this.activeUtilityPanel = panelId;
|
||||
this.showCopyHistory = panelId === 'history';
|
||||
this.showGlitchTokenPanel = panelId === 'glitch';
|
||||
this.showEndSequencePanel = panelId === 'endsequences';
|
||||
this.showUnicodePanel = panelId === 'settings';
|
||||
|
||||
if (isMobile) {
|
||||
this.mobileUtilityOpen = true;
|
||||
this.markUtilityPanelInteraction();
|
||||
}
|
||||
|
||||
if (panelId === 'glitch' && !this.glitchTokensLoaded) {
|
||||
this.loadGlitchTokens();
|
||||
}
|
||||
|
||||
if (panelId === 'history' && this.copyHistory.length > 0) {
|
||||
this.$nextTick(function() {
|
||||
var firstCopyButton = document.querySelector('.copy-again-button');
|
||||
if (firstCopyButton) {
|
||||
firstCopyButton.focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
toggleGlitchTokenPanel(event) {
|
||||
this.showGlitchTokenPanel = !this.showGlitchTokenPanel;
|
||||
|
||||
// Load tokens if not already loaded
|
||||
if (this.showGlitchTokenPanel && !this.glitchTokensLoaded) {
|
||||
this.loadGlitchTokens();
|
||||
|
||||
closeMobileUtility() {
|
||||
if (this.utilityPanelInteractionGuard) {
|
||||
return;
|
||||
}
|
||||
this.mobileUtilityOpen = false;
|
||||
},
|
||||
|
||||
toggleEndSequencePanel() {
|
||||
this.showEndSequencePanel = !this.showEndSequencePanel;
|
||||
openMobileUtility() {
|
||||
this.mobileUtilityOpen = true;
|
||||
this.markUtilityPanelInteraction();
|
||||
},
|
||||
|
||||
copyEndSequence(value) {
|
||||
@@ -337,6 +542,133 @@ window.app = new Vue({
|
||||
showCopiedPopup() {
|
||||
window.NotificationUtils.showCopiedPopup();
|
||||
},
|
||||
|
||||
getOpenRouterApiKey() {
|
||||
var key = (this.openrouterApiKey || '').trim();
|
||||
if (key) return key;
|
||||
if (window.OpenRouterModels && window.OpenRouterModels.getApiKey) {
|
||||
return window.OpenRouterModels.getApiKey();
|
||||
}
|
||||
return '';
|
||||
},
|
||||
|
||||
formatOpenRouterModelLabel(model) {
|
||||
if (window.OpenRouterModels && window.OpenRouterModels.formatLabel) {
|
||||
return window.OpenRouterModels.formatLabel(model);
|
||||
}
|
||||
return model && model.name ? model.name : '';
|
||||
},
|
||||
|
||||
isOpenRouterModelEnabled(modelId) {
|
||||
if (!window.OpenRouterModels) return true;
|
||||
return window.OpenRouterModels.isModelEnabled(modelId, this.openRouterModelsDisabled);
|
||||
},
|
||||
|
||||
rebuildOpenRouterDropdown() {
|
||||
if (!window.OpenRouterModels) return;
|
||||
var pinned = window.OpenRouterModels.getPinnedModelIds(this);
|
||||
this.openRouterModels = window.OpenRouterModels.filterForDropdown(
|
||||
this.openRouterModelsCatalog,
|
||||
this.openRouterModelsDisabled,
|
||||
pinned
|
||||
);
|
||||
},
|
||||
|
||||
toggleOpenRouterModelEnabled(modelId) {
|
||||
if (!modelId) return;
|
||||
var disabled = this.openRouterModelsDisabled.slice();
|
||||
var index = disabled.indexOf(modelId);
|
||||
if (index === -1) {
|
||||
disabled.push(modelId);
|
||||
} else {
|
||||
disabled.splice(index, 1);
|
||||
}
|
||||
this.openRouterModelsDisabled = disabled;
|
||||
if (window.OpenRouterModels) {
|
||||
window.OpenRouterModels.saveDisabledIds(disabled);
|
||||
}
|
||||
this.rebuildOpenRouterDropdown();
|
||||
this.syncOpenRouterModelSelections();
|
||||
},
|
||||
|
||||
enableAllOpenRouterModels() {
|
||||
this.openRouterModelsDisabled = [];
|
||||
if (window.OpenRouterModels) {
|
||||
window.OpenRouterModels.saveDisabledIds([]);
|
||||
}
|
||||
this.rebuildOpenRouterDropdown();
|
||||
this.syncOpenRouterModelSelections();
|
||||
},
|
||||
|
||||
showFreeOpenRouterModelsOnly() {
|
||||
var disabled = (this.openRouterModelsCatalog || [])
|
||||
.filter(function(model) {
|
||||
return model && model.id && !model.free && !model.virtual;
|
||||
})
|
||||
.map(function(model) { return model.id; });
|
||||
this.openRouterModelsDisabled = disabled;
|
||||
if (window.OpenRouterModels) {
|
||||
window.OpenRouterModels.saveDisabledIds(disabled);
|
||||
}
|
||||
this.rebuildOpenRouterDropdown();
|
||||
this.syncOpenRouterModelSelections();
|
||||
},
|
||||
|
||||
syncOpenRouterModelSelections() {
|
||||
if (!window.OpenRouterModels || !this.openRouterModels.length) return;
|
||||
var models = this.openRouterModels;
|
||||
var ensure = window.OpenRouterModels.ensureValidSelection.bind(window.OpenRouterModels);
|
||||
|
||||
if (typeof this.pcModel !== 'undefined') {
|
||||
this.pcModel = ensure(this.pcModel, models, localStorage.getItem('pc-model') || 'openrouter/auto');
|
||||
}
|
||||
if (typeof this.acModel !== 'undefined') {
|
||||
this.acModel = ensure(this.acModel, models, localStorage.getItem('ac-model') || 'openrouter/auto');
|
||||
}
|
||||
if (typeof this.saModel !== 'undefined') {
|
||||
this.saModel = ensure(this.saModel, models, localStorage.getItem('sa-model') || 'openrouter/free');
|
||||
}
|
||||
if (typeof this.translateModel !== 'undefined') {
|
||||
this.translateModel = ensure(this.translateModel, models, localStorage.getItem('translate-model') || 'google/gemma-3-27b-it');
|
||||
}
|
||||
},
|
||||
|
||||
refreshOpenRouterModels: async function(force) {
|
||||
if (!window.OpenRouterModels) return;
|
||||
if (this.openRouterModelsLoading) return;
|
||||
|
||||
this.openRouterModelsLoading = true;
|
||||
this.openRouterModelsError = '';
|
||||
|
||||
var apiKey = this.getOpenRouterApiKey();
|
||||
|
||||
try {
|
||||
var models = await window.OpenRouterModels.fetch(apiKey, { force: !!force });
|
||||
this.openRouterModelsCatalog = models;
|
||||
this.rebuildOpenRouterDropdown();
|
||||
this.syncOpenRouterModelSelections();
|
||||
|
||||
if (apiKey) {
|
||||
this.openRouterModelsKeyInfo = await window.OpenRouterModels.fetchKeyInfo(apiKey);
|
||||
} else {
|
||||
this.openRouterModelsKeyInfo = null;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('OpenRouter model fetch failed:', err);
|
||||
var fallback = window.OpenRouterModels.getStaticFallback();
|
||||
this.openRouterModelsCatalog = window.OpenRouterModels.mergeWithVirtual(fallback);
|
||||
this.rebuildOpenRouterDropdown();
|
||||
this.syncOpenRouterModelSelections();
|
||||
|
||||
if (err && err.status === 401) {
|
||||
this.openRouterModelsError = 'Invalid API key — check Settings.';
|
||||
} else {
|
||||
this.openRouterModelsError = (err && err.message) || 'Could not load models; using offline list.';
|
||||
}
|
||||
} finally {
|
||||
this.openRouterModelsLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
saveApiKey() {
|
||||
var trimmed = (this.openrouterApiKey || '').trim();
|
||||
@@ -346,6 +678,7 @@ window.app = new Vue({
|
||||
this.apiKeySaved = true;
|
||||
this.showNotification('API key saved', 'success');
|
||||
setTimeout(() => { this.apiKeySaved = false; }, 2000);
|
||||
this.refreshOpenRouterModels(true);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -356,6 +689,8 @@ window.app = new Vue({
|
||||
localStorage.removeItem('openrouter_api_key');
|
||||
localStorage.removeItem('plinyos-api-key');
|
||||
this.showNotification('API key cleared', 'success');
|
||||
this.openRouterModelsKeyInfo = null;
|
||||
this.refreshOpenRouterModels(true);
|
||||
},
|
||||
|
||||
setupPasteHandlers() {
|
||||
@@ -371,15 +706,19 @@ window.app = new Vue({
|
||||
}
|
||||
}),
|
||||
mounted() {
|
||||
if (window.ThemeUtils && window.ThemeUtils.initializeTheme) {
|
||||
this.isDarkTheme = window.ThemeUtils.initializeTheme();
|
||||
if (this.isDarkTheme) {
|
||||
document.body.classList.add('dark-theme');
|
||||
} else {
|
||||
document.body.classList.add('light-theme');
|
||||
}
|
||||
} else if (this.isDarkTheme) {
|
||||
document.body.classList.add('dark-theme');
|
||||
if (window.ThemeUtils) {
|
||||
this.activeTheme = window.ThemeUtils.applyTheme(window.ThemeUtils.initializeTheme());
|
||||
this._themeKeyHandler = (event) => {
|
||||
if (event.defaultPrevented || event.repeat) return;
|
||||
if (event.key !== 'd' && event.key !== 'D') return;
|
||||
var tag = event.target && event.target.tagName;
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || (event.target && event.target.isContentEditable)) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
this.cycleTheme();
|
||||
};
|
||||
document.addEventListener('keydown', this._themeKeyHandler);
|
||||
}
|
||||
|
||||
if (window.toolRegistry && typeof window.toolRegistry.mergeVueLifecycle === 'function') {
|
||||
@@ -392,20 +731,23 @@ window.app = new Vue({
|
||||
if (window.toolRegistry && typeof window.toolRegistry.getAll === 'function') {
|
||||
this.registeredTools = window.toolRegistry.getAll();
|
||||
}
|
||||
|
||||
this.$nextTick(() => {
|
||||
const closeButton = document.querySelector('#unicode-options-panel .close-button');
|
||||
if (closeButton) {
|
||||
const handleClose = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.toggleUnicodePanel(e);
|
||||
};
|
||||
|
||||
closeButton.addEventListener('click', handleClose, { passive: false });
|
||||
closeButton.addEventListener('touchend', handleClose, { passive: false });
|
||||
|
||||
this.refreshOpenRouterModels(false);
|
||||
|
||||
var initialRoute = window.TabRouting && window.TabRouting.parse();
|
||||
if (initialRoute && initialRoute.tab && this.getValidToolIds().includes(initialRoute.tab)) {
|
||||
this.applyRouteFromHash();
|
||||
} else if (window.toolRegistry) {
|
||||
window.toolRegistry.activateTool(this.activeTab, this);
|
||||
}
|
||||
|
||||
this._onHashChange = () => {
|
||||
if (window.TabRouting && window.TabRouting.shouldIgnoreHashChange()) {
|
||||
return;
|
||||
}
|
||||
});
|
||||
this.applyRouteFromHash();
|
||||
};
|
||||
window.addEventListener('hashchange', this._onHashChange);
|
||||
|
||||
document.addEventListener('click', (e) => {
|
||||
if (e.target.closest('.custom-tooltip')) {
|
||||
@@ -508,6 +850,16 @@ window.app = new Vue({
|
||||
},
|
||||
|
||||
beforeDestroy() {
|
||||
if (this._themeKeyHandler) {
|
||||
document.removeEventListener('keydown', this._themeKeyHandler);
|
||||
this._themeKeyHandler = null;
|
||||
}
|
||||
|
||||
if (this._onHashChange) {
|
||||
window.removeEventListener('hashchange', this._onHashChange);
|
||||
this._onHashChange = null;
|
||||
}
|
||||
|
||||
if (this._emojiGridInitializer) {
|
||||
clearInterval(this._emojiGridInitializer);
|
||||
this._emojiGridInitializer = null;
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
* Application Configuration Constants
|
||||
*/
|
||||
window.CONFIG = {
|
||||
APP_VERSION: '4.0.0',
|
||||
|
||||
// History configuration
|
||||
MAX_HISTORY_ITEMS: 50,
|
||||
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -181,6 +181,9 @@ if (typeof AntiClassifierTool !== 'undefined') {
|
||||
if (typeof BijectionTool !== 'undefined') {
|
||||
window.toolRegistry.register(new BijectionTool());
|
||||
}
|
||||
if (typeof CodesTool !== 'undefined') {
|
||||
window.toolRegistry.register(new CodesTool());
|
||||
}
|
||||
if (typeof DecodeTool !== 'undefined') {
|
||||
window.toolRegistry.register(new DecodeTool());
|
||||
}
|
||||
@@ -196,6 +199,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());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Shared transform picker dropdown (category optgroups + favorites).
|
||||
* Used by Splitter, Universal Decoder, and any tool that picks a transform.
|
||||
*/
|
||||
(function(global) {
|
||||
'use strict';
|
||||
|
||||
function transformSelectFilter(root, decodableOnly) {
|
||||
const list = (root && root.transforms) ? root.transforms : [];
|
||||
if (!decodableOnly) return list;
|
||||
return list.filter(function(t) {
|
||||
return t && typeof t.reverse === 'function' && t.name !== 'Random Mix';
|
||||
});
|
||||
}
|
||||
|
||||
function transformSelectFavorites(root, decodableOnly) {
|
||||
const favorites = (root && root.favorites) ? root.favorites : [];
|
||||
const pool = transformSelectFilter(root, decodableOnly);
|
||||
return favorites
|
||||
.filter(function(f) { return typeof f === 'string'; })
|
||||
.map(function(name) { return pool.find(function(t) { return t.name === name; }); })
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function transformSelectByCategory(root, category, decodableOnly) {
|
||||
const pool = transformSelectFilter(root, decodableOnly);
|
||||
const list = pool.filter(function(t) { return t.category === category; });
|
||||
const favorites = (root && root.favorites) ? root.favorites : [];
|
||||
if (!favorites.length) return list;
|
||||
return list.filter(function(t) {
|
||||
return !favorites.some(function(f) { return typeof f === 'string' && f === t.name; });
|
||||
});
|
||||
}
|
||||
|
||||
function transformSelectCategoryOrder(root) {
|
||||
if (root && Array.isArray(root.categories) && root.categories.length) {
|
||||
return root.categories;
|
||||
}
|
||||
const pool = transformSelectFilter(root, false);
|
||||
const set = new Set();
|
||||
pool.forEach(function(t) {
|
||||
if (t.category) set.add(t.category);
|
||||
});
|
||||
const cats = Array.from(set).filter(function(c) { return c !== 'randomizer'; }).sort();
|
||||
if (set.has('randomizer')) cats.push('randomizer');
|
||||
return cats;
|
||||
}
|
||||
|
||||
function transformSelectCategoryLabel(category) {
|
||||
if (!category) return '';
|
||||
return category.charAt(0).toUpperCase() + category.slice(1);
|
||||
}
|
||||
|
||||
function registerTransformSelectComponent(Vue) {
|
||||
Vue.component('transform-select', {
|
||||
props: {
|
||||
value: { type: String, default: '' },
|
||||
decodableOnly: { type: Boolean, default: false },
|
||||
includeAuto: { type: Boolean, default: false },
|
||||
showEmpty: { type: Boolean, default: false },
|
||||
emptyLabel: { type: String, default: 'None' },
|
||||
selectClass: { type: [String, Object, Array], default: 'transform-select' }
|
||||
},
|
||||
computed: {
|
||||
categoryOrder: function() {
|
||||
return transformSelectCategoryOrder(this.$root);
|
||||
},
|
||||
favoriteTransforms: function() {
|
||||
return transformSelectFavorites(this.$root, this.decodableOnly);
|
||||
},
|
||||
visibleCategories: function() {
|
||||
var self = this;
|
||||
return this.categoryOrder.filter(function(category) {
|
||||
return self.transformsForCategory(category).length > 0;
|
||||
});
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
categoryDisplayName: function(category) {
|
||||
return transformSelectCategoryLabel(category);
|
||||
},
|
||||
transformsForCategory: function(category) {
|
||||
return transformSelectByCategory(this.$root, category, this.decodableOnly);
|
||||
},
|
||||
onChange: function(event) {
|
||||
this.$emit('input', event.target.value);
|
||||
this.$emit('change', event);
|
||||
}
|
||||
},
|
||||
template: '\
|
||||
<select \
|
||||
:value="value" \
|
||||
@change="onChange" \
|
||||
:class="selectClass"> \
|
||||
<option v-if="showEmpty" value="">{{ emptyLabel }}</option> \
|
||||
<option v-if="includeAuto" value="auto">🔍 Auto-detect</option> \
|
||||
<optgroup v-if="favoriteTransforms.length > 0" label="⭐ Favorites"> \
|
||||
<option v-for="t in favoriteTransforms" :key="\'f-\' + t.name" :value="t.name">{{ t.name }}</option> \
|
||||
</optgroup> \
|
||||
<optgroup \
|
||||
v-for="category in visibleCategories" \
|
||||
:key="category" \
|
||||
:label="categoryDisplayName(category)"> \
|
||||
<option \
|
||||
v-for="t in transformsForCategory(category)" \
|
||||
:key="t.name" \
|
||||
:value="t.name">{{ t.name }}</option> \
|
||||
</optgroup> \
|
||||
</select>'
|
||||
});
|
||||
}
|
||||
|
||||
global.transformSelectFilter = transformSelectFilter;
|
||||
global.transformSelectFavorites = transformSelectFavorites;
|
||||
global.transformSelectByCategory = transformSelectByCategory;
|
||||
global.transformSelectCategoryOrder = transformSelectCategoryOrder;
|
||||
global.transformSelectCategoryLabel = transformSelectCategoryLabel;
|
||||
global.registerTransformSelectComponent = registerTransformSelectComponent;
|
||||
|
||||
if (typeof Vue !== 'undefined') {
|
||||
registerTransformSelectComponent(Vue);
|
||||
}
|
||||
})(typeof window !== 'undefined' ? window : global);
|
||||
+14
-45
@@ -1,50 +1,19 @@
|
||||
/**
|
||||
* Shared OpenRouter model list for PromptCraft, Anti-Classifier, etc.
|
||||
* Loaded before tool scripts.
|
||||
* Offline fallback when OpenRouter model API is unreachable.
|
||||
* Live lists are loaded via OpenRouterModels.fetch() using the user's API key.
|
||||
*/
|
||||
window.OPENROUTER_MODELS = [
|
||||
{ id: 'anthropic/claude-opus-4.6', name: 'Claude Opus 4.6', provider: 'Anthropic' },
|
||||
{ id: 'anthropic/claude-sonnet-4.6', name: 'Claude Sonnet 4.6', provider: 'Anthropic' },
|
||||
{ id: 'anthropic/claude-sonnet-4.5', name: 'Claude Sonnet 4.5', provider: 'Anthropic' },
|
||||
{ id: 'anthropic/claude-opus-4', name: 'Claude Opus 4', provider: 'Anthropic' },
|
||||
{ id: 'anthropic/claude-sonnet-4', name: 'Claude Sonnet 4', provider: 'Anthropic' },
|
||||
{ id: 'openai/gpt-5.4', name: 'GPT-5.4', provider: 'OpenAI' },
|
||||
{ id: 'openai/gpt-5.4-pro', name: 'GPT-5.4 Pro', provider: 'OpenAI' },
|
||||
{ id: 'openai/gpt-4.1', name: 'GPT-4.1', provider: 'OpenAI' },
|
||||
{ id: 'google/gemini-3.1-pro-preview', name: 'Gemini 3.1 Pro', provider: 'Google' },
|
||||
{ id: 'google/gemini-2.5-pro-preview', name: 'Gemini 2.5 Pro', provider: 'Google' },
|
||||
{ id: 'x-ai/grok-4.20-beta', name: 'Grok 4.20 Beta', provider: 'xAI' },
|
||||
{ id: 'x-ai/grok-4', name: 'Grok 4', provider: 'xAI' },
|
||||
{ id: 'deepseek/deepseek-v3.2', name: 'DeepSeek V3.2', provider: 'DeepSeek' },
|
||||
{ id: 'mistralai/mistral-large-3-2512', name: 'Mistral Large 3', provider: 'Mistral' },
|
||||
{ id: 'openai/o3-pro', name: 'o3-pro', provider: 'OpenAI' },
|
||||
{ id: 'openai/o3', name: 'o3', provider: 'OpenAI' },
|
||||
{ id: 'openai/o4-mini', name: 'o4-mini', provider: 'OpenAI' },
|
||||
{ id: 'deepseek/deepseek-r1-0528', name: 'DeepSeek R1 (0528)', provider: 'DeepSeek' },
|
||||
{ id: 'deepseek/deepseek-r1', name: 'DeepSeek R1', provider: 'DeepSeek' },
|
||||
{ id: 'qwen/qwq-32b', name: 'QwQ 32B', provider: 'Qwen' },
|
||||
{ id: 'anthropic/claude-haiku-4.5', name: 'Claude Haiku 4.5', provider: 'Anthropic' },
|
||||
{ id: 'openai/gpt-5.4-mini', name: 'GPT-5.4 Mini', provider: 'OpenAI' },
|
||||
{ id: 'openai/gpt-4.1-mini', name: 'GPT-4.1 Mini', provider: 'OpenAI' },
|
||||
{ id: 'openai/gpt-4.1-nano', name: 'GPT-4.1 Nano', provider: 'OpenAI' },
|
||||
{ id: 'google/gemini-3-flash-preview', name: 'Gemini 3 Flash', provider: 'Google' },
|
||||
{ id: 'google/gemini-2.5-flash-preview', name: 'Gemini 2.5 Flash', provider: 'Google' },
|
||||
{ id: 'google/gemini-2.5-flash-lite', name: 'Gemini 2.5 Flash Lite', provider: 'Google' },
|
||||
{ id: 'x-ai/grok-4.1-fast', name: 'Grok 4.1 Fast', provider: 'xAI' },
|
||||
window.OPENROUTER_MODELS_FALLBACK = [
|
||||
{ id: 'openrouter/free', name: 'Free router', summary: 'Zero cost — random free model', provider: 'OpenRouter', virtual: true },
|
||||
{ id: 'openrouter/auto', name: 'Auto router', summary: 'Smart routing — billed per model', provider: 'OpenRouter', virtual: true },
|
||||
{ id: 'google/gemma-3-27b-it', name: 'Gemma 3 27B', provider: 'Google' },
|
||||
{ id: 'qwen/qwen3-coder-480b-a35b-instruct', name: 'Qwen3 Coder 480B', provider: 'Qwen' },
|
||||
{ id: 'openai/gpt-5.3-codex', name: 'GPT-5.3 Codex', provider: 'OpenAI' },
|
||||
{ id: 'x-ai/grok-code-fast-1', name: 'Grok Code Fast 1', provider: 'xAI' },
|
||||
{ id: 'mistralai/devstral-2-2512', name: 'Devstral 2', provider: 'Mistral' },
|
||||
{ id: 'mistralai/codestral-2508', name: 'Codestral', provider: 'Mistral' },
|
||||
{ id: 'meta-llama/llama-4-maverick', name: 'Llama 4 Maverick', provider: 'Meta' },
|
||||
{ id: 'meta-llama/llama-4-scout', name: 'Llama 4 Scout', provider: 'Meta' },
|
||||
{ id: 'meta-llama/llama-3.3-70b-instruct', name: 'Llama 3.3 70B', provider: 'Meta' },
|
||||
{ id: 'qwen/qwen3-235b-a22b', name: 'Qwen3 235B', provider: 'Qwen' },
|
||||
{ id: 'google/gemma-3-12b-it', name: 'Gemma 3 12B', provider: 'Google' },
|
||||
{ id: 'anthropic/claude-sonnet-4.6', name: 'Claude Sonnet 4.6', provider: 'Anthropic' },
|
||||
{ id: 'openai/gpt-4.1', name: 'GPT-4.1', provider: 'OpenAI' },
|
||||
{ id: 'google/gemini-2.5-flash-preview', name: 'Gemini 2.5 Flash', provider: 'Google' },
|
||||
{ id: 'deepseek/deepseek-chat-v3-0324', name: 'DeepSeek V3', provider: 'DeepSeek' },
|
||||
{ id: 'cohere/command-a', name: 'Command A', provider: 'Cohere' },
|
||||
{ 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/auto', name: 'Auto (best for price)', provider: 'OpenRouter' }
|
||||
{ id: 'meta-llama/llama-3.3-70b-instruct', name: 'Llama 3.3 70B', provider: 'Meta' },
|
||||
{ id: 'nousresearch/hermes-3-llama-3.1-405b', name: 'Hermes 3 405B', provider: 'Nous' }
|
||||
];
|
||||
|
||||
/** @deprecated Use OPENROUTER_MODELS_FALLBACK or OpenRouterModels.fetch() */
|
||||
window.OPENROUTER_MODELS = window.OPENROUTER_MODELS_FALLBACK;
|
||||
|
||||
@@ -13,9 +13,6 @@ class AntiClassifierTool extends Tool {
|
||||
}
|
||||
|
||||
getVueData() {
|
||||
const models = (typeof window !== 'undefined' && window.OPENROUTER_MODELS && window.OPENROUTER_MODELS.length)
|
||||
? window.OPENROUTER_MODELS
|
||||
: [];
|
||||
const savedTemp = parseFloat(localStorage.getItem('ac-temperature'));
|
||||
const acTemperature = Number.isFinite(savedTemp)
|
||||
? Math.min(2, Math.max(0, savedTemp))
|
||||
@@ -26,8 +23,7 @@ class AntiClassifierTool extends Tool {
|
||||
acError: '',
|
||||
acLexemeAnalysis: { totalFindings: 0, findings: [], summary: 'No Latin-root wording findings.' },
|
||||
acLoading: false,
|
||||
acModel: localStorage.getItem('ac-model') || 'anthropic/claude-sonnet-4.6',
|
||||
acModels: models,
|
||||
acModel: localStorage.getItem('ac-model') || 'openrouter/auto',
|
||||
acTemperature,
|
||||
acMaxTokens: 2000
|
||||
};
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
/**
|
||||
* Codes Tool — generate and decode QR codes and barcodes.
|
||||
*/
|
||||
class CodesTool extends Tool {
|
||||
constructor() {
|
||||
super({
|
||||
id: 'codes',
|
||||
name: 'Codes',
|
||||
icon: 'fa-qrcode',
|
||||
title: 'QR codes and barcodes',
|
||||
order: 9
|
||||
});
|
||||
}
|
||||
|
||||
getVueData() {
|
||||
return {
|
||||
codesMode: 'generate',
|
||||
codesFormat: 'qr',
|
||||
codesInput: '',
|
||||
codesQrSize: 256,
|
||||
codesQrMargin: 2,
|
||||
codesQrEcl: 'M',
|
||||
codesBarcodeHeight: 80,
|
||||
codesBarcodeWidth: 2,
|
||||
codesBarcodeDisplayValue: true,
|
||||
codesOutputUrl: '',
|
||||
codesOutputSvg: '',
|
||||
codesError: '',
|
||||
codesDecodeResult: '',
|
||||
codesDecodeFormat: '',
|
||||
codesDecodeError: '',
|
||||
codesDecodeLoading: false,
|
||||
codesDecodePreview: ''
|
||||
};
|
||||
}
|
||||
|
||||
getVueMethods() {
|
||||
return {
|
||||
codesFormatLabel: function(format) {
|
||||
var labels = {
|
||||
qr: 'QR Code',
|
||||
code128: 'Code 128',
|
||||
ean13: 'EAN-13',
|
||||
code39: 'Code 39'
|
||||
};
|
||||
return labels[format] || format;
|
||||
},
|
||||
codesValidateGenerateInput: function() {
|
||||
var text = String(this.codesInput || '').trim();
|
||||
if (!text) {
|
||||
return 'Enter text or data to encode.';
|
||||
}
|
||||
if (this.codesFormat === 'ean13') {
|
||||
var digits = text.replace(/\D/g, '');
|
||||
if (!digits.length) {
|
||||
return 'EAN-13 requires digits only (12 or 13 digits). Letters and symbols are not valid.';
|
||||
}
|
||||
if (digits.length < 12 || digits.length > 13) {
|
||||
return 'EAN-13 requires 12 or 13 digits (check digit is calculated automatically for 12).';
|
||||
}
|
||||
}
|
||||
if (this.codesFormat === 'code39' && !/^[0-9A-Z\s\-\.\$\/\+\%]+$/.test(text.toUpperCase())) {
|
||||
return 'Code 39 supports A–Z, 0–9, space, and - . $ / + %.';
|
||||
}
|
||||
return '';
|
||||
},
|
||||
codesClearOutput: function() {
|
||||
this.codesOutputUrl = '';
|
||||
this.codesOutputSvg = '';
|
||||
},
|
||||
codesResetGenerate: function() {
|
||||
this.codesClearOutput();
|
||||
this.codesError = '';
|
||||
},
|
||||
codesGenerate: function() {
|
||||
var validationError = this.codesValidateGenerateInput();
|
||||
if (validationError) {
|
||||
this.codesClearOutput();
|
||||
this.codesError = validationError;
|
||||
return;
|
||||
}
|
||||
|
||||
this.codesError = '';
|
||||
this.codesClearOutput();
|
||||
|
||||
var text = String(this.codesInput || '').trim();
|
||||
if (this.codesFormat === 'ean13') {
|
||||
if (typeof Ean13Utils === 'undefined' || typeof Ean13Utils.normalize !== 'function') {
|
||||
this.codesError = 'EAN-13 helpers not loaded. Rebuild the app (npm run build).';
|
||||
return;
|
||||
}
|
||||
var ean13 = Ean13Utils.normalize(text);
|
||||
if (!ean13.ok) {
|
||||
this.codesError = ean13.error;
|
||||
return;
|
||||
}
|
||||
text = ean13.value;
|
||||
}
|
||||
if (this.codesFormat === 'code39') {
|
||||
text = text.toUpperCase();
|
||||
}
|
||||
|
||||
var self = this;
|
||||
|
||||
if (this.codesFormat === 'qr') {
|
||||
if (typeof window.QRCode === 'undefined' || typeof window.QRCode.toDataURL !== 'function') {
|
||||
this.codesError = 'QR library not loaded. Rebuild the app (npm run build).';
|
||||
return;
|
||||
}
|
||||
window.QRCode.toDataURL(text, {
|
||||
width: Math.max(128, Math.min(1024, Number(this.codesQrSize) || 256)),
|
||||
margin: Math.max(0, Math.min(20, Number(this.codesQrMargin) || 2)),
|
||||
errorCorrectionLevel: this.codesQrEcl || 'M'
|
||||
}).then(function(url) {
|
||||
self.codesOutputUrl = url;
|
||||
if (typeof self.showNotification === 'function') {
|
||||
self.showNotification('QR code generated', 'success', 'fas fa-qrcode');
|
||||
}
|
||||
}).catch(function(err) {
|
||||
self.codesError = (err && err.message) || 'Failed to generate QR code.';
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof window.JsBarcode === 'undefined') {
|
||||
this.codesError = 'Barcode library not loaded. Rebuild the app (npm run build).';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
var formatMap = {
|
||||
code128: 'CODE128',
|
||||
ean13: 'EAN13',
|
||||
code39: 'CODE39'
|
||||
};
|
||||
var svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
window.JsBarcode(svg, text, {
|
||||
format: formatMap[this.codesFormat] || 'CODE128',
|
||||
displayValue: !!this.codesBarcodeDisplayValue,
|
||||
height: Math.max(40, Math.min(200, Number(this.codesBarcodeHeight) || 80)),
|
||||
width: Math.max(1, Math.min(4, Number(this.codesBarcodeWidth) || 2)),
|
||||
margin: 10
|
||||
});
|
||||
var svgMarkup = new XMLSerializer().serializeToString(svg);
|
||||
this.codesOutputSvg = svgMarkup;
|
||||
this.codesOutputUrl = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svgMarkup);
|
||||
if (typeof this.showNotification === 'function') {
|
||||
this.showNotification(this.codesFormatLabel(this.codesFormat) + ' generated', 'success', 'fas fa-barcode');
|
||||
}
|
||||
} catch (err) {
|
||||
this.codesError = (err && err.message) || 'Failed to generate barcode.';
|
||||
}
|
||||
},
|
||||
codesRasterizeToPng: function(imageUrl) {
|
||||
return new Promise(function(resolve, reject) {
|
||||
var img = new Image();
|
||||
img.onload = function() {
|
||||
var canvas = document.createElement('canvas');
|
||||
canvas.width = img.naturalWidth || img.width;
|
||||
canvas.height = img.naturalHeight || img.height;
|
||||
var ctx = canvas.getContext('2d');
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.drawImage(img, 0, 0);
|
||||
resolve(canvas.toDataURL('image/png'));
|
||||
};
|
||||
img.onerror = function() {
|
||||
reject(new Error('Failed to render barcode image.'));
|
||||
};
|
||||
img.src = imageUrl;
|
||||
});
|
||||
},
|
||||
codesCopyImage: function() {
|
||||
var self = this;
|
||||
if (!this.codesOutputUrl) {
|
||||
return;
|
||||
}
|
||||
if (!navigator.clipboard || typeof navigator.clipboard.write !== 'function') {
|
||||
this.codesError = 'Image copy is not supported in this browser.';
|
||||
return;
|
||||
}
|
||||
|
||||
var blobPromise;
|
||||
if (this.codesOutputUrl.indexOf('data:image/png') === 0) {
|
||||
blobPromise = fetch(this.codesOutputUrl).then(function(response) {
|
||||
return response.blob();
|
||||
});
|
||||
} else {
|
||||
blobPromise = this.codesRasterizeToPng(this.codesOutputUrl).then(function(pngUrl) {
|
||||
return fetch(pngUrl).then(function(response) {
|
||||
return response.blob();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
blobPromise.then(function(blob) {
|
||||
return navigator.clipboard.write([
|
||||
new ClipboardItem({ 'image/png': blob })
|
||||
]);
|
||||
}).then(function() {
|
||||
if (typeof self.showNotification === 'function') {
|
||||
self.showNotification('Image copied to clipboard', 'success', 'fas fa-copy');
|
||||
}
|
||||
}).catch(function(err) {
|
||||
self.codesError = (err && err.message) || 'Failed to copy image to clipboard.';
|
||||
});
|
||||
},
|
||||
codesDownload: function() {
|
||||
if (!this.codesOutputUrl) {
|
||||
return;
|
||||
}
|
||||
var ext = this.codesFormat === 'qr' ? 'png' : 'svg';
|
||||
var link = document.createElement('a');
|
||||
link.href = this.codesOutputUrl;
|
||||
link.download = 'code-' + this.codesFormat + '.' + ext;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
if (typeof this.showNotification === 'function') {
|
||||
this.showNotification('Download started', 'success', 'fas fa-download');
|
||||
}
|
||||
},
|
||||
codesCopyOutput: function() {
|
||||
if (!this.codesInput.trim()) {
|
||||
return;
|
||||
}
|
||||
if (typeof this.copyToClipboard === 'function') {
|
||||
this.copyToClipboard(this.codesInput);
|
||||
} else if (navigator.clipboard) {
|
||||
navigator.clipboard.writeText(this.codesInput);
|
||||
}
|
||||
},
|
||||
codesResetDecode: function() {
|
||||
this.codesDecodeResult = '';
|
||||
this.codesDecodeFormat = '';
|
||||
this.codesDecodeError = '';
|
||||
this.codesDecodePreview = '';
|
||||
this.codesDecodeLoading = false;
|
||||
},
|
||||
codesHandleFileUpload: function(event) {
|
||||
var file = event && event.target && event.target.files && event.target.files[0];
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
this.codesDecodeFromFile(file);
|
||||
event.target.value = '';
|
||||
},
|
||||
codesDecodeFromFile: function(file) {
|
||||
var self = this;
|
||||
if (!file || !file.type.match(/^image\//)) {
|
||||
this.codesDecodeError = 'Choose a PNG, JPEG, GIF, or WebP image.';
|
||||
return;
|
||||
}
|
||||
if (typeof window.ZXing === 'undefined' || !window.ZXing.BrowserMultiFormatReader) {
|
||||
this.codesDecodeError = 'Scanner library not loaded. Rebuild the app (npm run build).';
|
||||
return;
|
||||
}
|
||||
|
||||
this.codesDecodeLoading = true;
|
||||
this.codesDecodeError = '';
|
||||
this.codesDecodeResult = '';
|
||||
this.codesDecodeFormat = '';
|
||||
|
||||
var reader = new FileReader();
|
||||
reader.onload = function(loadEvent) {
|
||||
self.codesDecodePreview = loadEvent.target.result;
|
||||
self.codesScanImage(loadEvent.target.result);
|
||||
};
|
||||
reader.onerror = function() {
|
||||
self.codesDecodeLoading = false;
|
||||
self.codesDecodeError = 'Could not read that file.';
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
},
|
||||
codesScanImage: function(dataUrl) {
|
||||
var self = this;
|
||||
var codeReader = new window.ZXing.BrowserMultiFormatReader();
|
||||
|
||||
codeReader.decodeFromImageUrl(dataUrl).then(function(result) {
|
||||
self.codesDecodeResult = result.getText();
|
||||
self.codesDecodeFormat = result.getBarcodeFormat
|
||||
? String(result.getBarcodeFormat())
|
||||
: 'unknown';
|
||||
self.codesDecodeError = '';
|
||||
if (typeof self.showNotification === 'function') {
|
||||
self.showNotification('Code decoded', 'success', 'fas fa-search');
|
||||
}
|
||||
}).catch(function() {
|
||||
self.codesDecodeResult = '';
|
||||
self.codesDecodeFormat = '';
|
||||
self.codesDecodeError = 'No QR code or barcode found in that image.';
|
||||
}).finally(function() {
|
||||
self.codesDecodeLoading = false;
|
||||
codeReader.reset();
|
||||
});
|
||||
},
|
||||
codesUseDecodedText: function() {
|
||||
if (!this.codesDecodeResult) {
|
||||
return;
|
||||
}
|
||||
this.codesMode = 'generate';
|
||||
this.codesInput = this.codesDecodeResult;
|
||||
if (typeof this.showNotification === 'function') {
|
||||
this.showNotification('Copied decoded text to Generate tab', 'success', 'fas fa-arrow-right');
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
getVueWatchers() {
|
||||
return {
|
||||
codesMode: function(mode) {
|
||||
this.codesError = '';
|
||||
if (this.activeTab === 'codes' && window.TabRouting) {
|
||||
window.TabRouting.setHash('codes', mode === 'decode' ? 'decode' : '');
|
||||
}
|
||||
},
|
||||
codesFormat: function() {
|
||||
this.codesResetGenerate();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
onActivate(vueInstance) {
|
||||
vueInstance.codesError = '';
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = CodesTool;
|
||||
} else {
|
||||
window.CodesTool = CodesTool;
|
||||
}
|
||||
@@ -135,7 +135,9 @@ class DecodeTool extends Tool {
|
||||
var self = this;
|
||||
return {
|
||||
getAllTransformsWithReverse: function() {
|
||||
return this.transforms.filter(t => t && typeof t.reverse === 'function');
|
||||
return (typeof transformSelectFilter === 'function')
|
||||
? transformSelectFilter(this, true)
|
||||
: this.transforms.filter(t => t && typeof t.reverse === 'function');
|
||||
},
|
||||
runUniversalDecode: function() {
|
||||
const input = this.decoderInput;
|
||||
|
||||
@@ -23,7 +23,7 @@ class PromptCraftTool extends Tool {
|
||||
pcOutputs: [],
|
||||
pcLexemeAnalysis: { totalFindings: 0, findings: [], summary: 'No Latin-root wording findings.' },
|
||||
pcStrategy: 'rephrase',
|
||||
pcModel: localStorage.getItem('pc-model') || 'nousresearch/hermes-3-llama-3.1-405b',
|
||||
pcModel: localStorage.getItem('pc-model') || 'openrouter/auto',
|
||||
pcTemperature,
|
||||
pcCount: 3,
|
||||
pcLoading: false,
|
||||
@@ -39,12 +39,7 @@ class PromptCraftTool extends Tool {
|
||||
{ id: 'metaphor', name: 'Metaphor', icon: 'fa-cloud', desc: 'Express through analogy and metaphor' },
|
||||
{ id: 'fragment', name: 'Fragment', icon: 'fa-puzzle-piece', desc: 'Split across disjointed fragments' },
|
||||
{ id: 'custom', name: 'Custom', icon: 'fa-pen-fancy', desc: 'Your own mutation instruction' }
|
||||
],
|
||||
pcModels: (typeof window !== 'undefined' && window.OPENROUTER_MODELS && window.OPENROUTER_MODELS.length)
|
||||
? window.OPENROUTER_MODELS
|
||||
: [
|
||||
{ id: 'openrouter/auto', name: 'Auto (best for price)', provider: 'OpenRouter' }
|
||||
]
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
/**
|
||||
* 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() {
|
||||
return {
|
||||
saView: 'list',
|
||||
saAlphabets: [],
|
||||
saEditingId: null,
|
||||
saName: '',
|
||||
saCategory: '',
|
||||
saAlphabet: SpellingAlphabetTransform.emptyAlphabet(),
|
||||
saLoading: false,
|
||||
saError: '',
|
||||
saModel: localStorage.getItem('sa-model') || 'openrouter/free',
|
||||
saLetters: SpellingAlphabetTransform.LETTERS
|
||||
};
|
||||
}
|
||||
|
||||
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 duplicateName = (this.saAlphabets || []).some(function(entry) {
|
||||
return entry.id !== this.saEditingId &&
|
||||
String(entry.name || '').trim().toLowerCase() === name.toLowerCase();
|
||||
}, this);
|
||||
if (duplicateName) {
|
||||
return 'Another spelling alphabet already uses this name. Choose a unique name.';
|
||||
}
|
||||
|
||||
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 vueInstance.refreshCustomSpellingTransforms === 'function') {
|
||||
vueInstance.refreshCustomSpellingTransforms();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = SpellingAlphabetTool;
|
||||
} else {
|
||||
window.SpellingAlphabetTool = SpellingAlphabetTool;
|
||||
}
|
||||
@@ -6,16 +6,13 @@ class SplitterTool extends Tool {
|
||||
super({
|
||||
id: 'splitter',
|
||||
name: 'Splitter',
|
||||
icon: 'fa-grip-lines',
|
||||
icon: 'fa-scissors',
|
||||
title: 'Split text into multiple copyable messages',
|
||||
order: 8
|
||||
});
|
||||
}
|
||||
|
||||
getVueData() {
|
||||
// Load category order (same as TransformTool)
|
||||
const categoryOrder = this.getCategoryOrder();
|
||||
|
||||
return {
|
||||
// Message Splitter Tab
|
||||
splitterInput: '',
|
||||
@@ -35,78 +32,12 @@ class SplitterTool extends Tool {
|
||||
splitterTokenizer: 'cl100k', // tokenizer for token-based mode
|
||||
splitterTokenCount: 3, // token count per chunk for token-based mode
|
||||
splitterPreserveEmptyLines: false, // preserve empty lines for line/sentence modes
|
||||
splitMessages: [],
|
||||
categoryOrder: categoryOrder
|
||||
splitMessages: []
|
||||
};
|
||||
}
|
||||
|
||||
getCategoryOrder() {
|
||||
// Get all categories from transforms
|
||||
if (!window.transforms) return [];
|
||||
|
||||
const categorySet = new Set();
|
||||
Object.values(window.transforms).forEach(transform => {
|
||||
if (transform.category) {
|
||||
categorySet.add(transform.category);
|
||||
}
|
||||
});
|
||||
|
||||
const allCategories = Array.from(categorySet);
|
||||
const savedOrder = this.loadCategoryOrder();
|
||||
|
||||
return this.mergeCategoryOrder(allCategories, savedOrder);
|
||||
}
|
||||
|
||||
loadCategoryOrder() {
|
||||
try {
|
||||
const saved = localStorage.getItem('transformCategoryOrder');
|
||||
if (saved) {
|
||||
return JSON.parse(saved);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to load category order:', e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
mergeCategoryOrder(allCategories, savedOrder) {
|
||||
// Always ensure randomizer is last
|
||||
const categoriesWithoutRandomizer = allCategories.filter(c => c !== 'randomizer');
|
||||
|
||||
if (!savedOrder || savedOrder.length === 0) {
|
||||
// Default: alphabetical, randomizer last
|
||||
const sorted = categoriesWithoutRandomizer.sort((a, b) => a.localeCompare(b));
|
||||
return [...sorted, 'randomizer'];
|
||||
}
|
||||
|
||||
// Use saved order, but filter out categories that no longer exist and remove duplicates
|
||||
const validSavedOrder = savedOrder
|
||||
.filter(cat => allCategories.includes(cat))
|
||||
.filter((cat, index, arr) => arr.indexOf(cat) === index); // Remove duplicates
|
||||
|
||||
// Find new categories not in saved order
|
||||
const newCategories = categoriesWithoutRandomizer.filter(cat => !validSavedOrder.includes(cat));
|
||||
|
||||
// Build final order: saved order (filtered, deduplicated) + new categories (alphabetically) + randomizer
|
||||
const finalOrder = [...validSavedOrder];
|
||||
if (newCategories.length > 0) {
|
||||
finalOrder.push(...newCategories.sort((a, b) => a.localeCompare(b)));
|
||||
}
|
||||
|
||||
// Ensure randomizer is always last and remove any duplicates
|
||||
const finalWithoutRandomizer = finalOrder.filter(c => c !== 'randomizer');
|
||||
const uniqueFinal = finalWithoutRandomizer.filter((cat, index, arr) => arr.indexOf(cat) === index);
|
||||
return [...uniqueFinal, 'randomizer'];
|
||||
}
|
||||
|
||||
getVueMethods() {
|
||||
return {
|
||||
/**
|
||||
* Get display name for category (capitalized)
|
||||
*/
|
||||
getCategoryDisplayName: function(category) {
|
||||
return category.charAt(0).toUpperCase() + category.slice(1);
|
||||
},
|
||||
/**
|
||||
* Set encapsulation start and end strings
|
||||
* @param {string} start - The start string
|
||||
|
||||
+187
-65
@@ -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 => {
|
||||
@@ -74,9 +54,62 @@ class TransformTool extends Tool {
|
||||
transformOptionPrefs: this.loadTransformOptionPrefs(),
|
||||
transformOptionsModalOpen: false,
|
||||
transformOptionsModalTransform: null,
|
||||
transformOptionsDraft: {}
|
||||
transformOptionsDraft: {},
|
||||
transformSearchQuery: '',
|
||||
transformCategoryFilter: ''
|
||||
};
|
||||
}
|
||||
|
||||
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]) => ({
|
||||
transformKey: key,
|
||||
customSpellingId: transform.customSpellingId || null,
|
||||
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 {
|
||||
@@ -219,6 +252,12 @@ class TransformTool extends Tool {
|
||||
const transform = this.transforms.find(t => t.name === transformName);
|
||||
return transform ? transform.category : 'special';
|
||||
},
|
||||
getTransformKey: function(transform) {
|
||||
if (!transform) {
|
||||
return '';
|
||||
}
|
||||
return transform.customSpellingId || transform.transformKey || transform.name;
|
||||
},
|
||||
/**
|
||||
* True if this transform should show the options gear (uses saved prefs + defaults in decoder).
|
||||
* Falls back to window.transforms when the Vue copy omits configurableOptions.
|
||||
@@ -358,6 +397,104 @@ class TransformTool extends Tool {
|
||||
!this.favorites.some(f => typeof f === 'string' && f === t.name)
|
||||
);
|
||||
},
|
||||
formatCategoryLabel: function(category) {
|
||||
return String(category || '').replace(/_/g, ' ');
|
||||
},
|
||||
toggleCategoryFilter: function(category) {
|
||||
this.transformCategoryFilter = this.transformCategoryFilter === category ? '' : category;
|
||||
},
|
||||
clearTransformFilters: function() {
|
||||
this.transformSearchQuery = '';
|
||||
this.transformCategoryFilter = '';
|
||||
},
|
||||
transformSearchActive: function() {
|
||||
return String(this.transformSearchQuery || '').trim().length > 0;
|
||||
},
|
||||
transformMatchesSearchText: function(text) {
|
||||
const query = String(this.transformSearchQuery || '').trim().toLowerCase();
|
||||
if (!query) {
|
||||
return true;
|
||||
}
|
||||
return String(text || '').toLowerCase().indexOf(query) !== -1;
|
||||
},
|
||||
transformMatchesSearch: function(transform) {
|
||||
return this.transformMatchesSearchText(transform && transform.name);
|
||||
},
|
||||
getFilteredTransformsByCategory: function(category) {
|
||||
const list = this.getTransformsByCategory(category);
|
||||
if (!this.transformSearchActive()) {
|
||||
return list;
|
||||
}
|
||||
return list.filter(t => this.transformMatchesSearch(t));
|
||||
},
|
||||
categorySectionVisible: function(category) {
|
||||
if (this.transformCategoryFilter && this.transformCategoryFilter !== category) {
|
||||
return false;
|
||||
}
|
||||
return this.getFilteredTransformsByCategory(category).length > 0;
|
||||
},
|
||||
displayItemMatchesFilters: function(item) {
|
||||
if (!item) {
|
||||
return false;
|
||||
}
|
||||
if (this.transformCategoryFilter) {
|
||||
if (item.type === 'translate') {
|
||||
return false;
|
||||
}
|
||||
if (item.type === 'transform' && item.transform) {
|
||||
const category = item.transform.category || this.getDisplayCategory(item.transform.name);
|
||||
if (category !== this.transformCategoryFilter) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!this.transformSearchActive()) {
|
||||
return true;
|
||||
}
|
||||
if (item.type === 'translate') {
|
||||
return this.transformMatchesSearchText(item.langName);
|
||||
}
|
||||
if (item.type === 'transform' && item.transform) {
|
||||
return this.transformMatchesSearch(item.transform);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
getFilteredFavoriteDisplayItems: function() {
|
||||
return this.getFavoriteDisplayItems().filter(item => this.displayItemMatchesFilters(item));
|
||||
},
|
||||
getFilteredLastUsedDisplayItems: function() {
|
||||
return this.getLastUsedDisplayItems().filter(item => this.displayItemMatchesFilters(item));
|
||||
},
|
||||
favoritesSectionVisible: function() {
|
||||
return this.showFavorites && this.getFilteredFavoriteDisplayItems().length > 0;
|
||||
},
|
||||
lastUsedSectionVisible: function() {
|
||||
return this.showLastUsed && this.getFilteredLastUsedDisplayItems().length > 0;
|
||||
},
|
||||
translateSectionVisible: function() {
|
||||
if (this.transformCategoryFilter) {
|
||||
return false;
|
||||
}
|
||||
if (!this.transformSearchActive()) {
|
||||
return true;
|
||||
}
|
||||
const langs = (this.translateMainLangs || [])
|
||||
.concat(this.translateExoticLangs || [])
|
||||
.concat(this.translateCustomLangs || []);
|
||||
return langs.some(lang => this.transformMatchesSearchText(lang.name));
|
||||
},
|
||||
translateLangVisible: function(langName) {
|
||||
return this.transformMatchesSearchText(langName);
|
||||
},
|
||||
transformListHasNoMatches: function() {
|
||||
if (!this.transformSearchActive() && !this.transformCategoryFilter) {
|
||||
return false;
|
||||
}
|
||||
if (this.favoritesSectionVisible() || this.lastUsedSectionVisible() || this.translateSectionVisible()) {
|
||||
return false;
|
||||
}
|
||||
return !this.categories.some(category => this.categorySectionVisible(category));
|
||||
},
|
||||
isSpecialCategory: function(category) {
|
||||
return category === 'randomizer';
|
||||
},
|
||||
@@ -609,45 +746,28 @@ class TransformTool extends Tool {
|
||||
this.transformOutput = window.EmojiUtils.joinEmojis(transformedSegments);
|
||||
}
|
||||
},
|
||||
initializeCategoryNavigation: function() {
|
||||
this.$nextTick(() => {
|
||||
const legendItems = document.querySelectorAll('.transform-category-legend .legend-item');
|
||||
legendItems.forEach(item => {
|
||||
const newItem = item.cloneNode(true);
|
||||
item.parentNode.replaceChild(newItem, item);
|
||||
});
|
||||
|
||||
document.querySelectorAll('.transform-category-legend .legend-item').forEach(item => {
|
||||
item.addEventListener('click', () => {
|
||||
const targetId = item.getAttribute('data-target');
|
||||
if (targetId) {
|
||||
const targetElement = document.getElementById(targetId);
|
||||
if (targetElement) {
|
||||
document.querySelectorAll('.transform-category-legend .legend-item').forEach(li => {
|
||||
li.classList.remove('active-category');
|
||||
});
|
||||
item.classList.add('active-category');
|
||||
|
||||
const inputSection = document.querySelector('.input-section');
|
||||
const inputSectionHeight = inputSection.offsetHeight;
|
||||
const elementPosition = targetElement.getBoundingClientRect().top + window.pageYOffset;
|
||||
const offsetPosition = elementPosition - inputSectionHeight - 10;
|
||||
|
||||
window.scrollTo({
|
||||
top: offsetPosition,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
|
||||
targetElement.classList.add('highlight-section');
|
||||
setTimeout(() => {
|
||||
targetElement.classList.remove('highlight-section');
|
||||
}, 1000);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
refreshCustomSpellingTransforms: function() {
|
||||
const transformTool = window.toolRegistry && window.toolRegistry.get('transforms');
|
||||
if (!transformTool || typeof transformTool.buildTransformsFromWindow !== 'function') {
|
||||
return;
|
||||
}
|
||||
|
||||
const previousCustomCount = (this.transforms || []).filter(function(t) {
|
||||
return t.category === 'custom_spelling';
|
||||
}).length;
|
||||
|
||||
this.transforms = transformTool.buildTransformsFromWindow();
|
||||
const categories = transformTool.rebuildTransformCategories(this.transforms);
|
||||
this.legendCategories = categories.legendCategories;
|
||||
this.categories = categories.sectionCategories;
|
||||
|
||||
const nextCustomCount = this.transforms.filter(function(t) {
|
||||
return t.category === 'custom_spelling';
|
||||
}).length;
|
||||
if (nextCustomCount !== previousCustomCount) {
|
||||
this.saveCategoryOrder(this.categories);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -673,7 +793,9 @@ class TransformTool extends Tool {
|
||||
getVueLifecycle() {
|
||||
return {
|
||||
mounted() {
|
||||
this.initializeCategoryNavigation();
|
||||
if (typeof this.refreshCustomSpellingTransforms === 'function') {
|
||||
this.refreshCustomSpellingTransforms();
|
||||
}
|
||||
|
||||
// Save initial category order to localStorage if it doesn't exist
|
||||
// This ensures consistent state for category reordering operations
|
||||
@@ -690,9 +812,9 @@ class TransformTool extends Tool {
|
||||
}
|
||||
|
||||
onActivate(vueInstance) {
|
||||
vueInstance.$nextTick(() => {
|
||||
vueInstance.initializeCategoryNavigation();
|
||||
});
|
||||
if (typeof vueInstance.refreshCustomSpellingTransforms === 'function') {
|
||||
vueInstance.refreshCustomSpellingTransforms();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -40,16 +40,6 @@ class TranslateTool extends Tool {
|
||||
translateError: '',
|
||||
translateActiveLang: '',
|
||||
translateModel: localStorage.getItem('translate-model') || 'google/gemma-3-27b-it',
|
||||
translateModels: [
|
||||
{ id: 'google/gemma-3-27b-it', name: 'Gemma 3 27B', note: 'Best quality' },
|
||||
{ id: 'google/gemma-3-12b-it', name: 'Gemma 3 12B', note: 'Fast + good' },
|
||||
{ id: 'google/gemma-3-4b-it', name: 'Gemma 3 4B', note: 'Fastest' },
|
||||
{ id: 'google/gemini-2.5-flash-preview', name: 'Gemini 2.5 Flash', note: 'Google flagship' },
|
||||
{ id: 'google/gemini-2.0-flash-001', name: 'Gemini 2.0 Flash', note: 'Stable' },
|
||||
{ id: 'google/translategemma-27b-it', name: 'TranslateGemma 27B', note: 'Purpose-built (if available)' },
|
||||
{ id: 'google/translategemma-12b-it', name: 'TranslateGemma 12B', note: 'Purpose-built (if available)' },
|
||||
{ id: 'google/translategemma-4b-it', name: 'TranslateGemma 4B', note: 'Purpose-built (if available)' }
|
||||
],
|
||||
translateMainLangs: [
|
||||
{ code: 'es', name: 'Spanish', flag: 'ES' },
|
||||
{ code: 'fr', name: 'French', flag: 'FR' },
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* EAN-13 normalization helpers for the Codes tool.
|
||||
*/
|
||||
(function(global) {
|
||||
'use strict';
|
||||
|
||||
function checksum(base12) {
|
||||
var sum = 0;
|
||||
for (var i = 0; i < 12; i++) {
|
||||
var digit = parseInt(base12.charAt(i), 10);
|
||||
sum += (i % 2 === 0) ? digit : digit * 3;
|
||||
}
|
||||
return String((10 - (sum % 10)) % 10);
|
||||
}
|
||||
|
||||
function upcChecksum(upc12) {
|
||||
var sum = 0;
|
||||
for (var i = 0; i < 11; i++) {
|
||||
var digit = parseInt(upc12.charAt(i), 10);
|
||||
sum += (i % 2 === 0) ? digit * 3 : digit;
|
||||
}
|
||||
return String((10 - (sum % 10)) % 10);
|
||||
}
|
||||
|
||||
function isValidUpcA(digits) {
|
||||
return digits.length === 12
|
||||
&& digits.charAt(0) === '0'
|
||||
&& upcChecksum(digits) === digits.charAt(11);
|
||||
}
|
||||
|
||||
function fromUpcA(upc12) {
|
||||
var ean12 = '0' + upc12.slice(0, 11);
|
||||
return ean12 + checksum(ean12);
|
||||
}
|
||||
|
||||
function normalize(input) {
|
||||
var digits = String(input || '').replace(/\D/g, '');
|
||||
if (digits.length < 12) {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'EAN-13 requires 12 or 13 digits.'
|
||||
};
|
||||
}
|
||||
if (digits.length > 13) {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'EAN-13 accepts at most 13 digits.'
|
||||
};
|
||||
}
|
||||
|
||||
if (digits.length === 12 && isValidUpcA(digits)) {
|
||||
return { ok: true, value: fromUpcA(digits) };
|
||||
}
|
||||
|
||||
var base12 = digits.length === 12 ? digits : digits.slice(0, 12);
|
||||
return { ok: true, value: base12 + checksum(base12) };
|
||||
}
|
||||
|
||||
var api = {
|
||||
checksum: checksum,
|
||||
upcChecksum: upcChecksum,
|
||||
isValidUpcA: isValidUpcA,
|
||||
fromUpcA: fromUpcA,
|
||||
normalize: normalize
|
||||
};
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = api;
|
||||
} else {
|
||||
global.Ean13Utils = api;
|
||||
}
|
||||
})(typeof window !== 'undefined' ? window : globalThis);
|
||||
@@ -0,0 +1,241 @@
|
||||
/**
|
||||
* Fetch, cache, and normalize OpenRouter model lists for UI dropdowns.
|
||||
*/
|
||||
window.OpenRouterModels = {
|
||||
CACHE_KEY: 'openrouter-models-cache-v1',
|
||||
CACHE_TTL_MS: 60 * 60 * 1000,
|
||||
|
||||
ENABLED_STORAGE_KEY: 'openrouter-models-disabled-v1',
|
||||
|
||||
VIRTUAL: [
|
||||
{
|
||||
id: 'openrouter/free',
|
||||
name: 'Free router',
|
||||
summary: 'Zero cost — random free model matched to your request',
|
||||
provider: 'OpenRouter',
|
||||
virtual: true,
|
||||
routerKind: 'free'
|
||||
},
|
||||
{
|
||||
id: 'openrouter/auto',
|
||||
name: 'Auto router',
|
||||
summary: 'Smart routing — billed at whichever model is picked',
|
||||
provider: 'OpenRouter',
|
||||
virtual: true,
|
||||
routerKind: 'auto'
|
||||
}
|
||||
],
|
||||
|
||||
getApiKey: function() {
|
||||
try {
|
||||
return (
|
||||
localStorage.getItem('openrouter-api-key') ||
|
||||
localStorage.getItem('plinyos-api-key') ||
|
||||
localStorage.getItem('openrouter_api_key') ||
|
||||
''
|
||||
).trim();
|
||||
} catch (e) {
|
||||
return '';
|
||||
}
|
||||
},
|
||||
|
||||
getStaticFallback: function() {
|
||||
if (window.OPENROUTER_MODELS_FALLBACK && window.OPENROUTER_MODELS_FALLBACK.length) {
|
||||
return window.OPENROUTER_MODELS_FALLBACK.slice();
|
||||
}
|
||||
if (window.OPENROUTER_MODELS && window.OPENROUTER_MODELS.length) {
|
||||
return window.OPENROUTER_MODELS.slice();
|
||||
}
|
||||
return this.VIRTUAL.slice();
|
||||
},
|
||||
|
||||
isFreePricing: function(pricing) {
|
||||
if (!pricing) return false;
|
||||
return String(pricing.prompt) === '0' && String(pricing.completion) === '0';
|
||||
},
|
||||
|
||||
normalize: function(raw) {
|
||||
var id = raw && raw.id ? raw.id : '';
|
||||
var provider = id.indexOf('/') !== -1 ? id.split('/')[0] : '';
|
||||
return {
|
||||
id: id,
|
||||
name: (raw && raw.name) || id,
|
||||
provider: provider,
|
||||
free: this.isFreePricing(raw && raw.pricing),
|
||||
contextLength: raw && raw.context_length ? raw.context_length : null,
|
||||
pricing: raw && raw.pricing ? raw.pricing : null
|
||||
};
|
||||
},
|
||||
|
||||
formatLabel: function(model) {
|
||||
if (!model) return '';
|
||||
if (model.virtual) {
|
||||
return model.name + ' — ' + (model.summary || model.provider);
|
||||
}
|
||||
var label = model.name || model.id;
|
||||
if (model.provider) {
|
||||
label += ' (' + model.provider + ')';
|
||||
}
|
||||
if (model.free) {
|
||||
label += ' · free';
|
||||
}
|
||||
return label;
|
||||
},
|
||||
|
||||
getRouterHint: function(modelId) {
|
||||
var match = this.VIRTUAL.find(function(model) {
|
||||
return model.id === modelId;
|
||||
});
|
||||
return match ? match.summary : '';
|
||||
},
|
||||
|
||||
loadDisabledIds: function() {
|
||||
try {
|
||||
var raw = localStorage.getItem(this.ENABLED_STORAGE_KEY);
|
||||
if (!raw) return [];
|
||||
var parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
saveDisabledIds: function(ids) {
|
||||
try {
|
||||
localStorage.setItem(this.ENABLED_STORAGE_KEY, JSON.stringify(ids || []));
|
||||
} catch (e) {
|
||||
console.warn('Failed to save OpenRouter model preferences:', e);
|
||||
}
|
||||
},
|
||||
|
||||
isModelEnabled: function(modelId, disabledIds) {
|
||||
if (!modelId) return true;
|
||||
disabledIds = disabledIds || [];
|
||||
return disabledIds.indexOf(modelId) === -1;
|
||||
},
|
||||
|
||||
filterForDropdown: function(catalog, disabledIds, pinnedIds) {
|
||||
catalog = catalog || [];
|
||||
disabledIds = disabledIds || [];
|
||||
pinnedIds = pinnedIds || [];
|
||||
|
||||
return catalog.filter(function(model) {
|
||||
if (!model || !model.id) return false;
|
||||
if (pinnedIds.indexOf(model.id) !== -1) return true;
|
||||
return disabledIds.indexOf(model.id) === -1;
|
||||
});
|
||||
},
|
||||
|
||||
getPinnedModelIds: function(context) {
|
||||
context = context || {};
|
||||
var ids = [];
|
||||
['pcModel', 'acModel', 'saModel', 'translateModel'].forEach(function(field) {
|
||||
if (context[field]) ids.push(context[field]);
|
||||
});
|
||||
return ids;
|
||||
},
|
||||
|
||||
loadCache: function(apiKey) {
|
||||
try {
|
||||
var raw = localStorage.getItem(this.CACHE_KEY);
|
||||
if (!raw) return null;
|
||||
var parsed = JSON.parse(raw);
|
||||
if (parsed.apiKey !== (apiKey || '')) return null;
|
||||
if (Date.now() - parsed.fetchedAt > this.CACHE_TTL_MS) return null;
|
||||
return Array.isArray(parsed.models) ? parsed.models : null;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
saveCache: function(apiKey, models) {
|
||||
try {
|
||||
localStorage.setItem(this.CACHE_KEY, JSON.stringify({
|
||||
apiKey: apiKey || '',
|
||||
fetchedAt: Date.now(),
|
||||
models: models
|
||||
}));
|
||||
} catch (e) {
|
||||
console.warn('Failed to cache OpenRouter models:', e);
|
||||
}
|
||||
},
|
||||
|
||||
mergeWithVirtual: function(models) {
|
||||
var seen = {};
|
||||
var merged = [];
|
||||
|
||||
this.VIRTUAL.forEach(function(model) {
|
||||
seen[model.id] = true;
|
||||
merged.push(Object.assign({}, model));
|
||||
});
|
||||
|
||||
(models || []).forEach(function(model) {
|
||||
if (!model || !model.id || seen[model.id]) return;
|
||||
seen[model.id] = true;
|
||||
merged.push(model);
|
||||
});
|
||||
|
||||
return merged;
|
||||
},
|
||||
|
||||
fetch: async function(apiKey, options) {
|
||||
options = options || {};
|
||||
var key = (apiKey || '').trim();
|
||||
|
||||
if (!options.force) {
|
||||
var cached = this.loadCache(key);
|
||||
if (cached && cached.length) {
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
|
||||
var url = key
|
||||
? 'https://openrouter.ai/api/v1/models/user'
|
||||
: 'https://openrouter.ai/api/v1/models?sort=most-popular';
|
||||
|
||||
var headers = {};
|
||||
if (key) {
|
||||
headers.Authorization = 'Bearer ' + key;
|
||||
}
|
||||
|
||||
var resp = await fetch(url, { headers: headers });
|
||||
if (!resp.ok) {
|
||||
var error = new Error('Failed to load models (HTTP ' + resp.status + ')');
|
||||
error.status = resp.status;
|
||||
throw error;
|
||||
}
|
||||
|
||||
var json = await resp.json();
|
||||
var models = (json.data || []).map(this.normalize.bind(this));
|
||||
models = this.mergeWithVirtual(models);
|
||||
this.saveCache(key, models);
|
||||
return models;
|
||||
},
|
||||
|
||||
fetchKeyInfo: async function(apiKey) {
|
||||
var key = (apiKey || '').trim();
|
||||
if (!key) return null;
|
||||
|
||||
try {
|
||||
var resp = await fetch('https://openrouter.ai/api/v1/key', {
|
||||
headers: { Authorization: 'Bearer ' + key }
|
||||
});
|
||||
if (!resp.ok) return null;
|
||||
var json = await resp.json();
|
||||
return json.data || null;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
ensureValidSelection: function(currentId, models, preferredId) {
|
||||
if (!models || !models.length) return currentId || preferredId || '';
|
||||
if (currentId && models.some(function(model) { return model.id === currentId; })) {
|
||||
return currentId;
|
||||
}
|
||||
if (preferredId && models.some(function(model) { return model.id === preferredId; })) {
|
||||
return preferredId;
|
||||
}
|
||||
return models[0].id;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Hash-based tab routing for static hosting (GitHub Pages friendly).
|
||||
* Examples: #decoder, #/transforms, #codes/decode
|
||||
*/
|
||||
(function(global) {
|
||||
var suppressHashChange = false;
|
||||
|
||||
function normalizeSegment(value) {
|
||||
return String(value || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function parseHash() {
|
||||
var raw = (global.location.hash || '').replace(/^#\/?/, '');
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
var slashIndex = raw.indexOf('/');
|
||||
if (slashIndex === -1) {
|
||||
return {
|
||||
tab: normalizeSegment(decodeURIComponent(raw)),
|
||||
sub: ''
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
tab: normalizeSegment(decodeURIComponent(raw.slice(0, slashIndex))),
|
||||
sub: normalizeSegment(decodeURIComponent(raw.slice(slashIndex + 1)))
|
||||
};
|
||||
}
|
||||
|
||||
function buildHash(tab, sub) {
|
||||
var hash = '#' + encodeURIComponent(String(tab || '').trim());
|
||||
if (sub) {
|
||||
hash += '/' + encodeURIComponent(String(sub).trim());
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
|
||||
function setHash(tab, sub, replace) {
|
||||
var next = buildHash(tab, sub);
|
||||
if (global.location.hash === next) {
|
||||
return;
|
||||
}
|
||||
|
||||
suppressHashChange = true;
|
||||
if (replace && global.history && typeof global.history.replaceState === 'function') {
|
||||
global.history.replaceState(null, '', next);
|
||||
suppressHashChange = false;
|
||||
return;
|
||||
}
|
||||
|
||||
global.location.hash = next;
|
||||
global.setTimeout(function() {
|
||||
suppressHashChange = false;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
global.TabRouting = {
|
||||
parse: parseHash,
|
||||
buildHash: buildHash,
|
||||
setHash: setHash,
|
||||
shouldIgnoreHashChange: function() {
|
||||
return suppressHashChange;
|
||||
}
|
||||
};
|
||||
})(typeof window !== 'undefined' ? window : globalThis);
|
||||
+75
-22
@@ -1,37 +1,90 @@
|
||||
/**
|
||||
* Theme registry and application.
|
||||
* Add entries to `themes` to expose new themes in the UI dropdown.
|
||||
*/
|
||||
window.ThemeUtils = {
|
||||
toggleTheme(currentTheme) {
|
||||
const newTheme = !currentTheme;
|
||||
|
||||
if (newTheme) {
|
||||
document.body.classList.add('dark-theme');
|
||||
document.body.classList.remove('light-theme');
|
||||
} else {
|
||||
document.body.classList.add('light-theme');
|
||||
document.body.classList.remove('dark-theme');
|
||||
defaultTheme: 'dark',
|
||||
|
||||
themes: [
|
||||
{ id: 'dark', name: 'Dark', icon: 'fa-moon' },
|
||||
{ id: 'light', name: 'Light', icon: 'fa-sun' },
|
||||
{ id: 'accessible', name: 'Accessible', icon: 'fa-universal-access' },
|
||||
{ id: 'bt6', name: 'BT6', icon: 'fa-shield-halved' },
|
||||
{ id: 'pliny', name: 'Pliny', icon: 'fa-dragon' },
|
||||
{ id: 'cyberpunk', name: 'Cyberpunk', icon: 'fa-city' },
|
||||
{ id: 'wildwest', name: 'Wild West', icon: 'fa-hat-cowboy' }
|
||||
],
|
||||
|
||||
getThemes() {
|
||||
return this.themes.slice();
|
||||
},
|
||||
|
||||
isValidTheme(themeId) {
|
||||
return this.themes.some(function(theme) {
|
||||
return theme.id === themeId;
|
||||
});
|
||||
},
|
||||
|
||||
normalizeThemeId(themeId) {
|
||||
if (this.isValidTheme(themeId)) {
|
||||
return themeId;
|
||||
}
|
||||
|
||||
return this.defaultTheme;
|
||||
},
|
||||
|
||||
applyTheme(themeId) {
|
||||
var resolved = this.normalizeThemeId(themeId);
|
||||
var body = document.body;
|
||||
|
||||
this.themes.forEach(function(theme) {
|
||||
body.classList.remove('theme-' + theme.id);
|
||||
});
|
||||
body.classList.remove('dark-theme', 'light-theme');
|
||||
|
||||
body.classList.add('theme-' + resolved);
|
||||
|
||||
// Legacy aliases used by a few rules / older saved state
|
||||
if (resolved === 'light' || resolved === 'accessible') {
|
||||
body.classList.add('light-theme');
|
||||
} else {
|
||||
body.classList.add('dark-theme');
|
||||
}
|
||||
|
||||
try {
|
||||
localStorage.setItem('theme', newTheme ? 'dark' : 'light');
|
||||
localStorage.setItem('theme', resolved);
|
||||
} catch (e) {
|
||||
console.warn('Failed to save theme preference:', e);
|
||||
}
|
||||
|
||||
return newTheme;
|
||||
|
||||
return resolved;
|
||||
},
|
||||
|
||||
|
||||
initializeTheme() {
|
||||
try {
|
||||
const saved = localStorage.getItem('theme');
|
||||
if (saved === 'light') {
|
||||
return false;
|
||||
} else if (saved === 'dark') {
|
||||
return true;
|
||||
var saved = localStorage.getItem('theme');
|
||||
if (saved) {
|
||||
return this.normalizeThemeId(saved);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to load theme preference:', e);
|
||||
}
|
||||
|
||||
return true;
|
||||
return this.defaultTheme;
|
||||
},
|
||||
|
||||
cycleTheme(currentThemeId) {
|
||||
var current = this.normalizeThemeId(currentThemeId);
|
||||
var index = this.themes.findIndex(function(theme) {
|
||||
return theme.id === current;
|
||||
});
|
||||
if (index === -1) {
|
||||
index = 0;
|
||||
}
|
||||
var next = this.themes[(index + 1) % this.themes.length];
|
||||
return this.applyTheme(next.id);
|
||||
},
|
||||
|
||||
/** Apply saved theme before Vue mounts (call from inline script in index). */
|
||||
applyInitialTheme() {
|
||||
return this.applyTheme(this.initializeTheme());
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+8
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Generated
+887
-9
File diff suppressed because it is too large
Load Diff
+9
-3
@@ -1,15 +1,17 @@
|
||||
{
|
||||
"name": "p4rs3lt0ngv3",
|
||||
"version": "1.0.0",
|
||||
"version": "4.0.0",
|
||||
"description": "Universal Text Encoder/Decoder & Steganography Tool",
|
||||
"scripts": {
|
||||
"build:codes-vendor": "node build/build-code-vendor.js",
|
||||
"build:copy": "node build/copy-static.js",
|
||||
"build:index": "node build/build-index.js",
|
||||
"build:tools": "node build/inject-tool-scripts.js",
|
||||
"build:templates": "node build/inject-tool-templates.js",
|
||||
"build:emoji": "node build/build-emoji-data.js",
|
||||
"build:transforms": "node build/build-transforms.js",
|
||||
"build": "npm run build:tools && npm run build:copy && npm run build:index && npm run build:transforms && npm run build:emoji && npm run build:templates",
|
||||
"build:alphabets": "node build/build-alphabet-transforms.js",
|
||||
"build:transforms": "npm run build:alphabets && node build/build-transforms.js",
|
||||
"build": "npm run build:tools && npm run build:codes-vendor && npm run build:copy && npm run build:index && npm run build:transforms && npm run build:emoji && npm run build:templates",
|
||||
"start": "serve dist -l 8080",
|
||||
"preview": "npm run build && serve dist -l 8080",
|
||||
"test": "node tests/test_universal.js",
|
||||
@@ -33,6 +35,10 @@
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@zxing/library": "^0.23.0",
|
||||
"esbuild": "^0.28.1",
|
||||
"jsbarcode": "^3.12.3",
|
||||
"qrcode": "^1.5.4",
|
||||
"serve": "^14.2.6"
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -15,7 +15,8 @@ p4rs3lt0ngv3-cli = "p4rs3lt0ngv3_cli.cli:main"
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"pytest>=8.3.5",
|
||||
"pytest>=9.0.3",
|
||||
"pygments>=2.20.0",
|
||||
]
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
|
||||
+18
-17
@@ -4,17 +4,18 @@ Transformers are instantiated using `BaseTransformer` class. Category is automat
|
||||
|
||||
## Directory Structure
|
||||
|
||||
Categories (auto-assigned from directory name):
|
||||
- `encoding/` - Base64, Hex, Binary, URL, HTML, etc.
|
||||
- `cipher/` - ROT13, Caesar, Vigenère, Atbash, etc.
|
||||
- `unicode/` - Cursive, Medieval, Monospace, Bubble, etc.
|
||||
- `case/` - Snake case, Kebab case, Title case, etc.
|
||||
- `technical/` - Morse, Braille, NATO, Brainfuck, etc.
|
||||
- `fantasy/` - Elder Futhark, Tengwar, Klingon, Aurebesh, etc.
|
||||
- `ancient/` - Hieroglyphics, Ogham, Roman Numerals, etc.
|
||||
- `format/` - Leetspeak, Pig Latin, Reverse, etc.
|
||||
- `visual/` - Emoji speak, Rovarspraket, etc.
|
||||
- `special/` - Randomizer, etc.
|
||||
Categories (auto-assigned from directory name by `npm run build:transforms`):
|
||||
- `case/` — Case and capitalization transforms
|
||||
- `cipher/` — Classical ciphers, transposition, polyalphabetic, A1Z26, tap code, codons, etc.
|
||||
- `concealment/` — Steganography and hidden-message schemes (null cipher, acrostic, zero-width, etc.)
|
||||
- `encoding/` — Byte and data encodings (Base64, hex, binary, line codes, Brainfuck, etc.)
|
||||
- `format/` — Text cleanup and layout utilities
|
||||
- `signwriting/` — SignWriting fingerspelling and related notation
|
||||
- `special/` — Randomizer and other misc tools
|
||||
- `symbol/` — Substitution alphabets, runes, scripts, pigpen, braille, numeral systems, etc.
|
||||
- `technical/` — Morse, NATO, phone keypad, spelling alphabets, semaphore, etc.
|
||||
- `unicode/` — Unicode presentation styles (bold, bubble, zalgo, etc.)
|
||||
- `visual/` — Spoken-language games and wordplay (Pig Latin, leetspeak, etc.)
|
||||
|
||||
## Creating a Transformer
|
||||
|
||||
@@ -129,12 +130,12 @@ Higher priority = more specific pattern (used for decoder result ordering):
|
||||
|
||||
## After Adding
|
||||
|
||||
1. Place file in appropriate category directory
|
||||
2. Run `npm run build:transforms`
|
||||
3. Test in webapp
|
||||
4. Add `detector` function if format has distinctive patterns
|
||||
5. Optionally add test cases to `tests/test_universal.js`
|
||||
6. Add a one-line description for the transform’s `name` in `DESCRIPTIONS` inside `build/readme-transform-section.js`, then run `node build/readme-transform-section.js` and merge the printed block into the **Text Transformations** section of the root `README.md` (the script exits with an error if a transform is missing from `DESCRIPTIONS`)
|
||||
1. Place the file in the appropriate category directory (folder name = UI category).
|
||||
2. Run `npm run build:transforms` (or `npm run build`).
|
||||
3. Test in the webapp (`npm start` → http://localhost:8080).
|
||||
4. Add a `detector` function if the format has distinctive patterns (helps the universal decoder).
|
||||
5. Optionally add known limitations to `limitations` in `tests/test_universal.js`.
|
||||
6. Update the root **README.md**: add or edit a bullet under **Text Transformations** in the matching category section, using the transform’s `name` and a one-line description.
|
||||
|
||||
## Testing
|
||||
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
// Acéré cipher — French solfège (A = Ré) with optional note-duration variants
|
||||
import BaseTransformer from '../BaseTransformer.js';
|
||||
|
||||
export default (function() {
|
||||
const SOLFEGE = ['Ré', 'Mi', 'Fa', 'Sol', 'La', 'Si', 'Do'];
|
||||
const SOLFEGE_ASCII = ['re', 'mi', 'fa', 'sol', 'la', 'si', 'do'];
|
||||
const EIGHTH = '♪';
|
||||
const HALF = '●';
|
||||
const WHOLE = '𝅝';
|
||||
const QUARTER = '♩';
|
||||
|
||||
/** Eighth then half; whole notes used for digits / extended tiers */
|
||||
const BLOCK_DURATIONS = {
|
||||
'full-alphabet': [EIGHTH, HALF, WHOLE, QUARTER],
|
||||
'eighth-then-half': [EIGHTH, HALF, WHOLE, QUARTER],
|
||||
'half-then-eighth': [HALF, EIGHTH, QUARTER, WHOLE],
|
||||
'first-half-eighth': null,
|
||||
'solfège-only': null,
|
||||
'an-septet': null
|
||||
};
|
||||
|
||||
function solfegeToPitch(token) {
|
||||
const key = token.toLowerCase().replace(/é/g, 'e');
|
||||
return SOLFEGE_ASCII.indexOf(key);
|
||||
}
|
||||
|
||||
function durationForLetterIndex(index, durationMode) {
|
||||
if (durationMode === 'solfège-only') return '';
|
||||
if (durationMode === 'first-half-eighth') {
|
||||
return index < 13 ? EIGHTH : HALF;
|
||||
}
|
||||
if (durationMode === 'an-septet') {
|
||||
return index < 7 ? EIGHTH : HALF;
|
||||
}
|
||||
const block = Math.floor(index / 7);
|
||||
const table = BLOCK_DURATIONS[durationMode] || BLOCK_DURATIONS['full-alphabet'];
|
||||
return table[block] || QUARTER;
|
||||
}
|
||||
|
||||
function durationForDigit(digit, durationMode) {
|
||||
if (durationMode === 'solfège-only') return '';
|
||||
return WHOLE;
|
||||
}
|
||||
|
||||
function encodeLetter(ch, options) {
|
||||
const upper = ch.toUpperCase();
|
||||
if (/[A-Z]/.test(upper)) {
|
||||
const index = upper.charCodeAt(0) - 65;
|
||||
const duration = durationForLetterIndex(index, options.durationMode);
|
||||
return duration + SOLFEGE[index % 7];
|
||||
}
|
||||
if (options.includeDigits && /[0-9]/.test(ch)) {
|
||||
const digit = parseInt(ch, 10);
|
||||
return durationForDigit(digit, options.durationMode) + SOLFEGE[digit % 7];
|
||||
}
|
||||
return ch;
|
||||
}
|
||||
|
||||
function matchingLetterIndices(pitch, duration, durationMode) {
|
||||
const out = [];
|
||||
for (let i = 0; i < 26; i++) {
|
||||
if (i % 7 !== pitch) continue;
|
||||
if (durationMode === 'solfège-only') {
|
||||
out.push(i);
|
||||
continue;
|
||||
}
|
||||
const expected = durationForLetterIndex(i, durationMode);
|
||||
if (!duration || duration === expected) out.push(i);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function parseToken(raw) {
|
||||
const chars = [...raw.trim()];
|
||||
if (!chars.length) return { duration: '', namePart: '' };
|
||||
const first = chars[0];
|
||||
if (first === EIGHTH || first === HALF || first === WHOLE || first === QUARTER) {
|
||||
return { duration: first, namePart: chars.slice(1).join('') };
|
||||
}
|
||||
return { duration: '', namePart: raw.trim() };
|
||||
}
|
||||
|
||||
function decodeToken(raw, options) {
|
||||
const t = raw.trim();
|
||||
if (!t) return '';
|
||||
|
||||
const { duration, namePart } = parseToken(t);
|
||||
|
||||
const pitch = solfegeToPitch(namePart);
|
||||
if (pitch < 0) return t;
|
||||
|
||||
if (duration === WHOLE && options.includeDigits && !/[A-Za-z]/.test(t)) {
|
||||
for (let d = 0; d <= 9; d++) {
|
||||
if (d % 7 === pitch) return String(d);
|
||||
}
|
||||
}
|
||||
|
||||
const candidates = matchingLetterIndices(pitch, duration, options.durationMode);
|
||||
if (candidates.length === 0) return t;
|
||||
return String.fromCharCode(65 + candidates[0]);
|
||||
}
|
||||
|
||||
function splitTokens(text, separator) {
|
||||
if (separator === 'none') {
|
||||
return text.match(/[♪●𝅝♩]?(?:Ré|Re|Mi|Fa|Sol|La|Si|Do)/gi) || [];
|
||||
}
|
||||
if (separator === 'comma') {
|
||||
return text.split(/\s*,\s*/).filter(Boolean);
|
||||
}
|
||||
return text.trim().split(/\s+/).filter(Boolean);
|
||||
}
|
||||
|
||||
return new BaseTransformer({
|
||||
name: 'Acéré Cipher',
|
||||
priority: 88,
|
||||
category: 'music',
|
||||
configurableOptions: [
|
||||
{
|
||||
id: 'durationMode',
|
||||
label: 'Note duration variant',
|
||||
type: 'select',
|
||||
default: 'full-alphabet',
|
||||
options: [
|
||||
{
|
||||
value: 'full-alphabet',
|
||||
label: '♪ / ● / 𝅝 / ♩ by septet (full A–Z, reversible)'
|
||||
},
|
||||
{
|
||||
value: 'an-septet',
|
||||
label: '♪ A–G, ● H–N (A–N only)'
|
||||
},
|
||||
{
|
||||
value: 'eighth-then-half',
|
||||
label: '♪ / ● / 𝅝 / ♩ septets (same as full)'
|
||||
},
|
||||
{
|
||||
value: 'half-then-eighth',
|
||||
label: 'Inverted septet durations'
|
||||
},
|
||||
{
|
||||
value: 'first-half-eighth',
|
||||
label: '♪ A–M, ● N–Z (lossy)'
|
||||
},
|
||||
{
|
||||
value: 'solfège-only',
|
||||
label: 'Solfège names only (lossy)'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'includeDigits',
|
||||
label: 'Encode digits 0–9 (0 = Ré, 𝅝 whole note)',
|
||||
type: 'boolean',
|
||||
default: false
|
||||
},
|
||||
{
|
||||
id: 'lettersOnly',
|
||||
label: 'Letters (and digits) only on encode',
|
||||
type: 'boolean',
|
||||
default: true
|
||||
},
|
||||
{
|
||||
id: 'separator',
|
||||
label: 'Separator between notes',
|
||||
type: 'select',
|
||||
default: 'space',
|
||||
options: [
|
||||
{ value: 'space', label: 'Space' },
|
||||
{ value: 'comma', label: 'Comma' },
|
||||
{ value: 'none', label: 'None' }
|
||||
]
|
||||
}
|
||||
],
|
||||
func: function(text, options) {
|
||||
options = options || {};
|
||||
const durationMode = options.durationMode || 'full-alphabet';
|
||||
const opts = {
|
||||
durationMode,
|
||||
includeDigits: !!options.includeDigits
|
||||
};
|
||||
const sep = options.separator === 'comma' ? ', '
|
||||
: (options.separator === 'none' ? '' : ' ');
|
||||
const chars = options.lettersOnly !== false
|
||||
? [...text].filter(c => /[A-Za-z0-9]/.test(c) && (opts.includeDigits || /[A-Za-z]/.test(c)))
|
||||
: [...text];
|
||||
const parts = chars.map(c => encodeLetter(c, opts));
|
||||
return options.separator === 'none' ? parts.join('') : parts.join(sep);
|
||||
},
|
||||
reverse: function(text, options) {
|
||||
options = options || {};
|
||||
const opts = {
|
||||
durationMode: options.durationMode || 'full-alphabet',
|
||||
includeDigits: !!options.includeDigits
|
||||
};
|
||||
const tokens = splitTokens(text, options.separator || 'space');
|
||||
return tokens.map(t => decodeToken(t, opts)).join('');
|
||||
},
|
||||
preview: function(text, options) {
|
||||
if (!text) return '[acéré]';
|
||||
return this.func(text.slice(0, 8), options) + '...';
|
||||
},
|
||||
detector: function(text) {
|
||||
const solfegeHits = (text.match(/\b(ré|re|mi|fa|sol|la|si|do)\b/gi) || []).length;
|
||||
const symbolPairs = (text.match(/[♪●𝅝♩](?:Ré|Re|Mi|Fa|Sol|La|Si|Do)/g) || []).length;
|
||||
return solfegeHits >= 2 || symbolPairs >= 2;
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,162 @@
|
||||
// ADFGVX cipher (WWI extension of ADFGX with digits)
|
||||
import BaseTransformer from '../BaseTransformer.js';
|
||||
|
||||
export default new BaseTransformer({
|
||||
name: 'ADFGVX Cipher',
|
||||
priority: 60,
|
||||
category: 'cipher',
|
||||
key: 'KEYWORD',
|
||||
coords: ['A', 'D', 'F', 'G', 'V', 'X'],
|
||||
square: [
|
||||
['A', 'B', 'C', 'D', 'E', 'F'],
|
||||
['G', 'H', 'I', 'J', 'K', 'L'],
|
||||
['M', 'N', 'O', 'P', 'Q', 'R'],
|
||||
['S', 'T', 'U', 'V', 'W', 'X'],
|
||||
['Y', 'Z', '0', '1', '2', '3'],
|
||||
['4', '5', '6', '7', '8', '9']
|
||||
],
|
||||
configurableOptions: [
|
||||
{
|
||||
id: 'polybiusKey',
|
||||
label: 'Polybius square keyword',
|
||||
type: 'text',
|
||||
default: ''
|
||||
},
|
||||
{
|
||||
id: 'key',
|
||||
label: 'Transposition keyword',
|
||||
type: 'text',
|
||||
default: 'KEYWORD'
|
||||
}
|
||||
],
|
||||
_buildSquare: function(polybiusKey) {
|
||||
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||
const key = String(polybiusKey || '').toUpperCase().replace(/[^A-Z0-9]/g, '').replace(/J/g, 'I');
|
||||
const seen = new Set();
|
||||
let cells = '';
|
||||
for (let i = 0; i < key.length; i++) {
|
||||
const c = key[i];
|
||||
if (!seen.has(c)) {
|
||||
seen.add(c);
|
||||
cells += c;
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < alphabet.length; i++) {
|
||||
const c = alphabet[i];
|
||||
if (!seen.has(c)) {
|
||||
seen.add(c);
|
||||
cells += c;
|
||||
}
|
||||
}
|
||||
cells = cells.padEnd(36, 'X').slice(0, 36);
|
||||
const square = [];
|
||||
for (let row = 0; row < 6; row++) {
|
||||
square.push(cells.slice(row * 6, row * 6 + 6).split(''));
|
||||
}
|
||||
return square;
|
||||
},
|
||||
_squareFor: function(options) {
|
||||
options = options || {};
|
||||
const polybiusKey = String(options.polybiusKey || '').trim();
|
||||
return polybiusKey ? this._buildSquare(polybiusKey) : this.square;
|
||||
},
|
||||
_transKey: function(options) {
|
||||
const k = options && options.key !== undefined && options.key !== null
|
||||
? String(options.key)
|
||||
: null;
|
||||
return (k || this.key || 'KEYWORD').toUpperCase().replace(/[^A-Z]/g, '');
|
||||
},
|
||||
_charToPair: function(char, square) {
|
||||
const c = char === 'J' ? 'I' : char;
|
||||
for (let row = 0; row < 6; row++) {
|
||||
for (let col = 0; col < 6; col++) {
|
||||
if (square[row][col] === c) {
|
||||
return this.coords[row] + this.coords[col];
|
||||
}
|
||||
}
|
||||
}
|
||||
return '';
|
||||
},
|
||||
_pairToChar: function(pair, square) {
|
||||
if (pair.length !== 2) return '';
|
||||
const row = this.coords.indexOf(pair[0]);
|
||||
const col = this.coords.indexOf(pair[1]);
|
||||
if (row < 0 || col < 0) return '';
|
||||
return square[row][col];
|
||||
},
|
||||
_columnarEncode: function(text, transKey) {
|
||||
const numCols = transKey.length;
|
||||
const numRows = Math.ceil(text.length / numCols);
|
||||
const grid = [];
|
||||
let idx = 0;
|
||||
for (let row = 0; row < numRows; row++) {
|
||||
grid[row] = [];
|
||||
for (let col = 0; col < numCols; col++) {
|
||||
grid[row][col] = idx < text.length ? text[idx++] : '';
|
||||
}
|
||||
}
|
||||
const keyOrder = transKey.split('').map((char, index) => ({ char, index }))
|
||||
.sort((a, b) => (a.char === b.char ? a.index - b.index : a.char.localeCompare(b.char)));
|
||||
let result = '';
|
||||
for (const item of keyOrder) {
|
||||
for (let row = 0; row < numRows; row++) {
|
||||
if (grid[row][item.index]) result += grid[row][item.index];
|
||||
}
|
||||
}
|
||||
return result;
|
||||
},
|
||||
_columnarDecode: function(text, transKey) {
|
||||
const numCols = transKey.length;
|
||||
const numRows = Math.ceil(text.length / numCols);
|
||||
const keyOrder = transKey.split('').map((char, index) => ({ char, index }))
|
||||
.sort((a, b) => (a.char === b.char ? a.index - b.index : a.char.localeCompare(b.char)));
|
||||
const grid = Array.from({ length: numRows }, () => new Array(numCols));
|
||||
let textIdx = 0;
|
||||
for (const item of keyOrder) {
|
||||
const colLen = Math.ceil((text.length - item.index) / numCols);
|
||||
for (let row = 0; row < colLen && textIdx < text.length; row++) {
|
||||
grid[row][item.index] = text[textIdx++];
|
||||
}
|
||||
}
|
||||
let out = '';
|
||||
for (let row = 0; row < numRows; row++) {
|
||||
for (let col = 0; col < numCols; col++) {
|
||||
if (grid[row][col]) out += grid[row][col];
|
||||
}
|
||||
}
|
||||
return out;
|
||||
},
|
||||
func: function(text, options) {
|
||||
options = options || {};
|
||||
const transKey = this._transKey(options);
|
||||
if (!transKey.length) return text;
|
||||
const square = this._squareFor(options);
|
||||
const cleaned = text.toUpperCase().replace(/[^A-Z0-9]/g, '').replace(/J/g, 'I');
|
||||
if (!cleaned.length) return text;
|
||||
let pairs = '';
|
||||
for (const char of cleaned) pairs += this._charToPair(char, square);
|
||||
return this._columnarEncode(pairs, transKey);
|
||||
},
|
||||
reverse: function(text, options) {
|
||||
options = options || {};
|
||||
const transKey = this._transKey(options);
|
||||
if (!transKey.length) return text;
|
||||
const square = this._squareFor(options);
|
||||
const cleaned = text.toUpperCase().replace(/[^ADFGVX]/g, '');
|
||||
if (!cleaned.length || cleaned.length % 2 !== 0) return text;
|
||||
const pairs = this._columnarDecode(cleaned, transKey);
|
||||
let result = '';
|
||||
for (let i = 0; i < pairs.length; i += 2) {
|
||||
result += this._pairToChar(pairs.slice(i, i + 2), square);
|
||||
}
|
||||
return result;
|
||||
},
|
||||
preview: function(text, options) {
|
||||
if (!text) return '[adfgvx]';
|
||||
return this.func(text.slice(0, 4), options).slice(0, 12) + '...';
|
||||
},
|
||||
detector: function(text) {
|
||||
const cleaned = text.replace(/\s/g, '').toUpperCase();
|
||||
return cleaned.length >= 10 && cleaned.length % 2 === 0 && /^[ADFGVX]+$/.test(cleaned);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
// AMSCO cipher (alternating 1/2 column heights + keyword transposition)
|
||||
import BaseTransformer from '../BaseTransformer.js';
|
||||
|
||||
export default new BaseTransformer({
|
||||
name: 'AMSCO Cipher',
|
||||
priority: 60,
|
||||
category: 'cipher',
|
||||
key: 'KEY',
|
||||
configurableOptions: [
|
||||
{ id: 'key', label: 'Keyword', type: 'text', default: 'KEY' }
|
||||
],
|
||||
_key: function(options) {
|
||||
const k = options && options.key != null ? String(options.key) : null;
|
||||
return (k || this.key || 'KEY').toUpperCase().replace(/[^A-Z]/g, '');
|
||||
},
|
||||
_colCapacity: function(colIndex) {
|
||||
return colIndex % 2 === 0 ? 1 : 2;
|
||||
},
|
||||
_sortedColumns: function(key) {
|
||||
return key.split('').map((char, index) => ({ char, index }))
|
||||
.sort((a, b) => (a.char === b.char ? a.index - b.index : a.char.localeCompare(b.char)));
|
||||
},
|
||||
_columnLengths: function(textLen, numCols) {
|
||||
const lens = new Array(numCols).fill(0);
|
||||
let idx = 0;
|
||||
while (idx < textLen) {
|
||||
for (let col = 0; col < numCols && idx < textLen; col++) {
|
||||
const take = Math.min(this._colCapacity(col), textLen - idx);
|
||||
lens[col] += take;
|
||||
idx += take;
|
||||
}
|
||||
}
|
||||
return lens;
|
||||
},
|
||||
func: function(text, options) {
|
||||
const key = this._key(options);
|
||||
if (!key.length) return text;
|
||||
const cleaned = text.toUpperCase().replace(/[^A-Z]/g, '');
|
||||
if (!cleaned.length) return text;
|
||||
|
||||
const numCols = key.length;
|
||||
const columns = Array.from({ length: numCols }, () => '');
|
||||
let idx = 0;
|
||||
while (idx < cleaned.length) {
|
||||
for (let col = 0; col < numCols && idx < cleaned.length; col++) {
|
||||
const take = Math.min(this._colCapacity(col), cleaned.length - idx);
|
||||
columns[col] += cleaned.slice(idx, idx + take);
|
||||
idx += take;
|
||||
}
|
||||
}
|
||||
|
||||
let out = '';
|
||||
for (const item of this._sortedColumns(key)) out += columns[item.index];
|
||||
return out;
|
||||
},
|
||||
reverse: function(text, options) {
|
||||
const key = this._key(options);
|
||||
if (!key.length) return text;
|
||||
const cleaned = text.toUpperCase().replace(/[^A-Z]/g, '');
|
||||
if (!cleaned.length) return text;
|
||||
|
||||
const numCols = key.length;
|
||||
const lens = this._columnLengths(cleaned.length, numCols);
|
||||
const columns = Array.from({ length: numCols }, () => '');
|
||||
let idx = 0;
|
||||
for (const item of this._sortedColumns(key)) {
|
||||
const len = lens[item.index];
|
||||
columns[item.index] = cleaned.slice(idx, idx + len);
|
||||
idx += len;
|
||||
}
|
||||
|
||||
const colOffsets = new Array(numCols).fill(0);
|
||||
let out = '';
|
||||
let remaining = cleaned.length;
|
||||
while (remaining > 0) {
|
||||
for (let col = 0; col < numCols && remaining > 0; col++) {
|
||||
const take = Math.min(this._colCapacity(col), columns[col].length - colOffsets[col], remaining);
|
||||
if (take <= 0) continue;
|
||||
out += columns[col].slice(colOffsets[col], colOffsets[col] + take);
|
||||
colOffsets[col] += take;
|
||||
remaining -= take;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
},
|
||||
preview: function(text, options) {
|
||||
if (!text) return '[amsco]';
|
||||
return this.func(text.slice(0, 8), options).slice(0, 12) + '...';
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
// Book cipher (word indices in reference text)
|
||||
import BaseTransformer from '../BaseTransformer.js';
|
||||
|
||||
export default (function() {
|
||||
const DEFAULT_BOOK = 'the quick brown fox jumps over the lazy dog hello world while pack my box with five dozen liquor jugs';
|
||||
|
||||
return new BaseTransformer({
|
||||
name: 'Book Cipher',
|
||||
priority: 55,
|
||||
category: 'cipher',
|
||||
configurableOptions: [
|
||||
{ id: 'book', label: 'Reference text (book)', type: 'text', default: DEFAULT_BOOK },
|
||||
{
|
||||
id: 'separator',
|
||||
label: 'Index separator',
|
||||
type: 'select',
|
||||
default: ' ',
|
||||
options: [
|
||||
{ value: ' ', label: 'Space' },
|
||||
{ value: '-', label: 'Dash' },
|
||||
{ value: '.', label: 'Dot' }
|
||||
]
|
||||
}
|
||||
],
|
||||
_bookWords: function(options) {
|
||||
const book = String(options && options.book != null ? options.book : DEFAULT_BOOK).toLowerCase();
|
||||
return book.match(/[a-z0-9']+/g) || [];
|
||||
},
|
||||
func: function(text, options) {
|
||||
options = options || {};
|
||||
const words = this._bookWords(options);
|
||||
const sep = options.separator != null ? String(options.separator) : ' ';
|
||||
if (!words.length) return text;
|
||||
const inputWords = text.match(/[a-zA-Z0-9']+/g) || [];
|
||||
const indices = inputWords.map(w => {
|
||||
const target = w.toLowerCase();
|
||||
const idx = words.indexOf(target);
|
||||
return idx >= 0 ? String(idx + 1) : '?';
|
||||
});
|
||||
return indices.join(sep);
|
||||
},
|
||||
reverse: function(text, options) {
|
||||
options = options || {};
|
||||
const words = this._bookWords(options);
|
||||
if (!words.length) return text;
|
||||
const sep = options.separator != null ? String(options.separator) : ' ';
|
||||
const esc = sep.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const parts = text.trim().split(new RegExp(esc.length ? esc : '\\s+'));
|
||||
return parts.map(p => {
|
||||
const n = parseInt(p, 10);
|
||||
if (!Number.isFinite(n) || n < 1 || n > words.length) return p;
|
||||
return words[n - 1];
|
||||
}).join(' ');
|
||||
},
|
||||
preview: function(text, options) {
|
||||
if (!text) return '[book]';
|
||||
return this.func(text.slice(0, 20), options).slice(0, 20) + '...';
|
||||
},
|
||||
detector: function(text) {
|
||||
const cleaned = text.trim();
|
||||
return /^[\d\s.\-]+$/.test(cleaned) && cleaned.replace(/\D/g, '').length >= 2;
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,54 @@
|
||||
// Genetic codons — letters encoded as DNA triplets (RNA codon table, simplified)
|
||||
import BaseTransformer from '../BaseTransformer.js';
|
||||
|
||||
export default (function() {
|
||||
const MAP = {
|
||||
'A': 'GCT', 'B': 'TGT', 'C': 'TGT', 'D': 'GAT', 'E': 'GAA', 'F': 'TTT',
|
||||
'G': 'GGT', 'H': 'CAT', 'I': 'ATT', 'J': 'TAT', 'K': 'AAA', 'L': 'TTA',
|
||||
'M': 'ATG', 'N': 'AAT', 'O': 'TAT', 'P': 'CCT', 'Q': 'CAA', 'R': 'CGT',
|
||||
'S': 'TCT', 'T': 'ACT', 'U': 'TGT', 'V': 'GTT', 'W': 'TGG', 'X': 'TAG',
|
||||
'Y': 'TAT', 'Z': 'TGT'
|
||||
};
|
||||
const REV = {};
|
||||
for (const [k, v] of Object.entries(MAP)) {
|
||||
if (!REV[v]) REV[v] = k;
|
||||
}
|
||||
|
||||
return new BaseTransformer({
|
||||
name: 'Codons (Genetic Code)',
|
||||
priority: 84,
|
||||
category: 'encoding',
|
||||
configurableOptions: [
|
||||
{
|
||||
id: 'separator',
|
||||
label: 'Separator between codons',
|
||||
type: 'select',
|
||||
default: 'space',
|
||||
options: [
|
||||
{ value: 'space', label: 'Space' },
|
||||
{ value: 'none', label: 'None' }
|
||||
]
|
||||
}
|
||||
],
|
||||
func: function(text, options) {
|
||||
options = options || {};
|
||||
const sep = options.separator === 'none' ? '' : ' ';
|
||||
return [...text.toUpperCase()].filter(c => /[A-Z]/.test(c)).map(c => MAP[c] || c).join(sep);
|
||||
},
|
||||
reverse: function(text, options) {
|
||||
options = options || {};
|
||||
const tokens = options.separator === 'none'
|
||||
? text.match(/[ACGT]{3}/gi) || []
|
||||
: text.trim().split(/\s+/);
|
||||
return tokens.map(t => REV[t.toUpperCase()] || '').join('');
|
||||
},
|
||||
preview: function(text, options) {
|
||||
if (!text) return '[codon]';
|
||||
return this.func(text.slice(0, 6), options);
|
||||
},
|
||||
detector: function(text) {
|
||||
const tokens = text.trim().split(/\s+/);
|
||||
return tokens.length >= 2 && tokens.every(t => /^[ACGT]{3}$/i.test(t));
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,89 @@
|
||||
// Double columnar transposition cipher
|
||||
import BaseTransformer from '../BaseTransformer.js';
|
||||
|
||||
export default (function() {
|
||||
function columnarEncode(text, key, pad) {
|
||||
pad = pad || 'X';
|
||||
const numCols = key.length;
|
||||
const numRows = Math.ceil(text.length / numCols);
|
||||
const grid = [];
|
||||
let idx = 0;
|
||||
for (let row = 0; row < numRows; row++) {
|
||||
grid[row] = [];
|
||||
for (let col = 0; col < numCols; col++) {
|
||||
grid[row][col] = idx < text.length ? text[idx++] : pad;
|
||||
}
|
||||
}
|
||||
const order = key.split('').map((char, index) => ({ char, index }))
|
||||
.sort((a, b) => (a.char === b.char ? a.index - b.index : a.char.localeCompare(b.char)));
|
||||
let out = '';
|
||||
for (const item of order) {
|
||||
for (let row = 0; row < numRows; row++) out += grid[row][item.index];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function columnarDecode(text, key, pad) {
|
||||
pad = pad || 'X';
|
||||
const numCols = key.length;
|
||||
const numRows = Math.ceil(text.length / numCols);
|
||||
const order = key.split('').map((char, index) => ({ char, index }))
|
||||
.sort((a, b) => (a.char === b.char ? a.index - b.index : a.char.localeCompare(b.char)));
|
||||
const grid = Array.from({ length: numRows }, () => new Array(numCols));
|
||||
let textIdx = 0;
|
||||
for (const item of order) {
|
||||
for (let row = 0; row < numRows && textIdx < text.length; row++) {
|
||||
grid[row][item.index] = text[textIdx++];
|
||||
}
|
||||
}
|
||||
let out = '';
|
||||
for (let row = 0; row < numRows; row++) {
|
||||
for (let col = 0; col < numCols; col++) {
|
||||
if (grid[row][col]) out += grid[row][col];
|
||||
}
|
||||
}
|
||||
return out.replace(new RegExp((pad || 'X').replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '+$'), '');
|
||||
}
|
||||
|
||||
return new BaseTransformer({
|
||||
name: 'Double Transposition',
|
||||
priority: 60,
|
||||
category: 'cipher',
|
||||
key1: 'FIRST',
|
||||
key2: 'SECOND',
|
||||
configurableOptions: [
|
||||
{ id: 'key1', label: 'First keyword', type: 'text', default: 'FIRST' },
|
||||
{ id: 'key2', label: 'Second keyword', type: 'text', default: 'SECOND' },
|
||||
{
|
||||
id: 'padChar',
|
||||
label: 'Padding character',
|
||||
type: 'text',
|
||||
default: 'X'
|
||||
}
|
||||
],
|
||||
_keys: function(options) {
|
||||
options = options || {};
|
||||
const k1 = String(options.key1 != null ? options.key1 : this.key1).toUpperCase().replace(/[^A-Z]/g, '');
|
||||
const k2 = String(options.key2 != null ? options.key2 : this.key2).toUpperCase().replace(/[^A-Z]/g, '');
|
||||
const padChar = String(options.padChar != null ? options.padChar : 'X').charAt(0) || 'X';
|
||||
return { k1: k1 || 'FIRST', k2: k2 || 'SECOND', padChar };
|
||||
},
|
||||
func: function(text, options) {
|
||||
const { k1, k2, padChar } = this._keys(options);
|
||||
const cleaned = text.replace(/\s/g, '').toUpperCase();
|
||||
if (!cleaned.length) return text;
|
||||
return columnarEncode(columnarEncode(cleaned, k1, padChar), k2, padChar);
|
||||
},
|
||||
reverse: function(text, options) {
|
||||
const { k1, k2, padChar } = this._keys(options);
|
||||
const cleaned = text.replace(/\s/g, '').toUpperCase();
|
||||
if (!cleaned.length) return text;
|
||||
const esc = padChar.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
return columnarDecode(columnarDecode(cleaned, k2, padChar), k1, padChar).replace(new RegExp(esc + '+$'), '');
|
||||
},
|
||||
preview: function(text, options) {
|
||||
if (!text) return '[double-trans]';
|
||||
return this.func(text.slice(0, 8), options).slice(0, 12) + '...';
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,134 @@
|
||||
// Fractionated Morse cipher (Morse pairs mapped via Polybius-style square)
|
||||
import BaseTransformer from '../BaseTransformer.js';
|
||||
|
||||
export default (function() {
|
||||
const MORSE = {
|
||||
'A': '.-', 'B': '-...', 'C': '-.-.', 'D': '-..', 'E': '.', 'F': '..-.',
|
||||
'G': '--.', 'H': '....', 'I': '..', 'J': '.---', 'K': '-.-', 'L': '.-..',
|
||||
'M': '--', 'N': '-.', 'O': '---', 'P': '.--.', 'Q': '--.-', 'R': '.-.',
|
||||
'S': '...', 'T': '-', 'U': '..-', 'V': '...-', 'W': '.--', 'X': '-..-',
|
||||
'Y': '-.--', 'Z': '--..'
|
||||
};
|
||||
const REV_MORSE = Object.fromEntries(Object.entries(MORSE).map(([k, v]) => [v, k]));
|
||||
const 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']
|
||||
];
|
||||
const SYM = ['.', '-'];
|
||||
|
||||
function morseStream(text) {
|
||||
return text.toUpperCase().replace(/[^A-Z]/g, '').split('')
|
||||
.map(c => MORSE[c] || '')
|
||||
.join('');
|
||||
}
|
||||
|
||||
function streamToText(stream) {
|
||||
const chars = [];
|
||||
let buf = '';
|
||||
for (const s of stream) {
|
||||
if (s === '.' || s === '-') buf += s;
|
||||
else if (buf) {
|
||||
chars.push(buf);
|
||||
buf = '';
|
||||
}
|
||||
}
|
||||
if (buf) chars.push(buf);
|
||||
let out = '';
|
||||
for (const code of chars) out += REV_MORSE[code] || '';
|
||||
return out;
|
||||
}
|
||||
|
||||
function pairToChar(a, b) {
|
||||
const row = SYM.indexOf(a);
|
||||
const col = SYM.indexOf(b);
|
||||
if (row < 0 || col < 0) return '';
|
||||
return SQUARE[row][col];
|
||||
}
|
||||
|
||||
function charToPair(ch) {
|
||||
const c = ch === 'J' ? 'I' : ch;
|
||||
for (let r = 0; r < 5; r++) {
|
||||
for (let col = 0; col < 5; col++) {
|
||||
if (SQUARE[r][col] === c) return SYM[r] + SYM[col];
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
return new BaseTransformer({
|
||||
name: 'Fractionated Morse',
|
||||
priority: 65,
|
||||
category: 'technical',
|
||||
configurableOptions: [
|
||||
{
|
||||
id: 'letterSeparator',
|
||||
label: 'Separator between letters',
|
||||
type: 'text',
|
||||
default: ''
|
||||
},
|
||||
{
|
||||
id: 'wordSeparator',
|
||||
label: 'Separator between words',
|
||||
type: 'select',
|
||||
default: ' ',
|
||||
options: [
|
||||
{ value: ' ', label: 'Space' },
|
||||
{ value: ' / ', label: 'Slash (Morse style)' },
|
||||
{ value: ' | ', label: 'Pipe' },
|
||||
{ value: '', label: 'None' }
|
||||
]
|
||||
}
|
||||
],
|
||||
_opts: function(options) {
|
||||
options = options || {};
|
||||
return {
|
||||
letterSeparator: options.letterSeparator != null ? String(options.letterSeparator) : '',
|
||||
wordSeparator: options.wordSeparator != null ? String(options.wordSeparator) : ' '
|
||||
};
|
||||
},
|
||||
func: function(text, options) {
|
||||
const { letterSeparator, wordSeparator } = this._opts(options);
|
||||
const words = text.toUpperCase().match(/[A-Z]+/g) || [];
|
||||
if (!words.length) {
|
||||
return text;
|
||||
}
|
||||
return words.map(function(word) {
|
||||
const stream = morseStream(word);
|
||||
let out = '';
|
||||
for (let i = 0; i < stream.length; i += 2) {
|
||||
if (i + 1 < stream.length) {
|
||||
if (out && letterSeparator) {
|
||||
out += letterSeparator;
|
||||
}
|
||||
out += pairToChar(stream[i], stream[i + 1]);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}).join(wordSeparator);
|
||||
},
|
||||
reverse: function(text, options) {
|
||||
const { letterSeparator, wordSeparator } = this._opts(options);
|
||||
let chunks = [text];
|
||||
if (wordSeparator) {
|
||||
chunks = text.split(wordSeparator);
|
||||
}
|
||||
return chunks.map(function(chunk) {
|
||||
const cleaned = letterSeparator
|
||||
? chunk.split(letterSeparator).join('')
|
||||
: chunk;
|
||||
let pairs = '';
|
||||
for (const ch of cleaned.toUpperCase().replace(/[^A-Z]/g, '')) {
|
||||
pairs += charToPair(ch);
|
||||
}
|
||||
return streamToText(pairs);
|
||||
}).join(' ');
|
||||
},
|
||||
preview: function(text, options) {
|
||||
if (!text) return '[frac-morse]';
|
||||
return this.func(text.slice(0, 6), options) + '...';
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,58 @@
|
||||
// Keyword shift cipher (Caesar shift from keyword letters)
|
||||
import BaseTransformer from '../BaseTransformer.js';
|
||||
|
||||
export default new BaseTransformer({
|
||||
name: 'Keyword Shift Cipher',
|
||||
priority: 60,
|
||||
category: 'cipher',
|
||||
key: 'KEY',
|
||||
configurableOptions: [
|
||||
{ id: 'key', label: 'Keyword', type: 'text', default: 'KEY' }
|
||||
],
|
||||
_keyStr: function(options) {
|
||||
const k = options && options.key != null ? String(options.key) : null;
|
||||
return (k || this.key || 'KEY').toUpperCase().replace(/[^A-Z]/g, '');
|
||||
},
|
||||
_shiftChar: function(c, shift, decode) {
|
||||
const code = c.charCodeAt(0);
|
||||
const s = ((shift % 26) + 26) % 26;
|
||||
const delta = decode ? (26 - s) % 26 : s;
|
||||
if (code >= 65 && code <= 90) return String.fromCharCode(((code - 65 + delta) % 26) + 65);
|
||||
if (code >= 97 && code <= 122) return String.fromCharCode(((code - 97 + delta) % 26) + 97);
|
||||
return c;
|
||||
},
|
||||
func: function(text, options) {
|
||||
const key = this._keyStr(options);
|
||||
if (!key.length) return text;
|
||||
let out = '';
|
||||
let j = 0;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const c = text[i];
|
||||
if (/[a-zA-Z]/.test(c)) {
|
||||
const shift = key[j % key.length].charCodeAt(0) - 65;
|
||||
out += this._shiftChar(c, shift, false);
|
||||
j++;
|
||||
} else out += c;
|
||||
}
|
||||
return out;
|
||||
},
|
||||
reverse: function(text, options) {
|
||||
const key = this._keyStr(options);
|
||||
if (!key.length) return text;
|
||||
let out = '';
|
||||
let j = 0;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const c = text[i];
|
||||
if (/[a-zA-Z]/.test(c)) {
|
||||
const shift = key[j % key.length].charCodeAt(0) - 65;
|
||||
out += this._shiftChar(c, shift, true);
|
||||
j++;
|
||||
} else out += c;
|
||||
}
|
||||
return out;
|
||||
},
|
||||
preview: function(text, options) {
|
||||
if (!text) return '[keyword-shift]';
|
||||
return this.func(text.slice(0, 8), options) + '...';
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
// Monoalphabetic substitution cipher (custom 26-letter alphabet)
|
||||
import BaseTransformer from '../BaseTransformer.js';
|
||||
|
||||
export default (function() {
|
||||
const PLAIN = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||||
|
||||
return new BaseTransformer({
|
||||
name: 'Monoalphabetic Substitution',
|
||||
priority: 60,
|
||||
category: 'cipher',
|
||||
configurableOptions: [
|
||||
{
|
||||
id: 'keyword',
|
||||
label: 'Keyword (builds cipher alphabet when set)',
|
||||
type: 'text',
|
||||
default: ''
|
||||
},
|
||||
{
|
||||
id: 'alphabet',
|
||||
label: 'Cipher alphabet (26 unique letters A-Z)',
|
||||
type: 'text',
|
||||
default: 'QWERTYUIOPASDFGHJKLZXCVBNM'
|
||||
}
|
||||
],
|
||||
_keywordAlpha: function(keyword) {
|
||||
const kw = String(keyword || '').toUpperCase().replace(/[^A-Z]/g, '');
|
||||
const seen = new Set();
|
||||
let alpha = '';
|
||||
for (let i = 0; i < kw.length; i++) {
|
||||
const c = kw[i];
|
||||
if (!seen.has(c)) {
|
||||
seen.add(c);
|
||||
alpha += c;
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < PLAIN.length; i++) {
|
||||
const c = PLAIN[i];
|
||||
if (!seen.has(c)) {
|
||||
seen.add(c);
|
||||
alpha += c;
|
||||
}
|
||||
}
|
||||
return alpha.slice(0, 26);
|
||||
},
|
||||
_cipherAlpha: function(options) {
|
||||
const keyword = String(options && options.keyword != null ? options.keyword : '').trim();
|
||||
if (keyword) {
|
||||
return this._keywordAlpha(keyword);
|
||||
}
|
||||
const raw = String(options && options.alphabet != null ? options.alphabet : 'QWERTYUIOPASDFGHJKLZXCVBNM')
|
||||
.toUpperCase()
|
||||
.replace(/[^A-Z]/g, '');
|
||||
const seen = new Set();
|
||||
let alpha = '';
|
||||
for (const c of raw) {
|
||||
if (!seen.has(c)) {
|
||||
seen.add(c);
|
||||
alpha += c;
|
||||
}
|
||||
}
|
||||
for (const c of PLAIN) {
|
||||
if (!seen.has(c)) {
|
||||
seen.add(c);
|
||||
alpha += c;
|
||||
}
|
||||
}
|
||||
return alpha.slice(0, 26);
|
||||
},
|
||||
_maps: function(options) {
|
||||
const cipher = this._cipherAlpha(options);
|
||||
const enc = {};
|
||||
const dec = {};
|
||||
for (let i = 0; i < 26; i++) {
|
||||
enc[PLAIN[i]] = cipher[i];
|
||||
enc[PLAIN[i].toLowerCase()] = cipher[i].toLowerCase();
|
||||
dec[cipher[i]] = PLAIN[i];
|
||||
dec[cipher[i].toLowerCase()] = PLAIN[i].toLowerCase();
|
||||
}
|
||||
return { enc, dec };
|
||||
},
|
||||
func: function(text, options) {
|
||||
const { enc } = this._maps(options || {});
|
||||
return [...text].map(c => enc[c] || c).join('');
|
||||
},
|
||||
reverse: function(text, options) {
|
||||
const { dec } = this._maps(options || {});
|
||||
return [...text].map(c => dec[c] || c).join('');
|
||||
},
|
||||
preview: function(text, options) {
|
||||
if (!text) return '[mono]';
|
||||
return this.func(text.slice(0, 8), options) + '...';
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,50 @@
|
||||
// Multiplicative cipher (mod 26; key must be coprime with 26)
|
||||
import BaseTransformer from '../BaseTransformer.js';
|
||||
|
||||
export default (function() {
|
||||
const COPRIMES = [1, 3, 5, 7, 9, 11, 15, 17, 19, 21, 23, 25];
|
||||
const INVERSES = { 1: 1, 3: 9, 5: 21, 7: 15, 9: 3, 11: 19, 15: 7, 17: 23, 19: 11, 21: 5, 23: 17, 25: 25 };
|
||||
|
||||
return new BaseTransformer({
|
||||
name: 'Multiplicative Cipher',
|
||||
priority: 60,
|
||||
category: 'cipher',
|
||||
multiplier: 5,
|
||||
configurableOptions: [
|
||||
{
|
||||
id: 'multiplier',
|
||||
label: 'Multiplier (coprime with 26)',
|
||||
type: 'select',
|
||||
default: '5',
|
||||
options: COPRIMES.map(n => ({ value: String(n), label: String(n) }))
|
||||
}
|
||||
],
|
||||
_mult: function(options) {
|
||||
const m = Number(options && options.multiplier != null ? options.multiplier : this.multiplier);
|
||||
return COPRIMES.includes(m) ? m : 5;
|
||||
},
|
||||
func: function(text, options) {
|
||||
const mult = this._mult(options || {});
|
||||
return [...text].map(c => {
|
||||
const code = c.charCodeAt(0);
|
||||
if (code >= 65 && code <= 90) return String.fromCharCode(((code - 65) * mult % 26) + 65);
|
||||
if (code >= 97 && code <= 122) return String.fromCharCode(((code - 97) * mult % 26) + 97);
|
||||
return c;
|
||||
}).join('');
|
||||
},
|
||||
reverse: function(text, options) {
|
||||
const mult = this._mult(options || {});
|
||||
const inv = INVERSES[mult] || 21;
|
||||
return [...text].map(c => {
|
||||
const code = c.charCodeAt(0);
|
||||
if (code >= 65 && code <= 90) return String.fromCharCode(((code - 65) * inv % 26) + 65);
|
||||
if (code >= 97 && code <= 122) return String.fromCharCode(((code - 97) * inv % 26) + 97);
|
||||
return c;
|
||||
}).join('');
|
||||
},
|
||||
preview: function(text, options) {
|
||||
if (!text) return '[mult]';
|
||||
return this.func(text.slice(0, 6), options) + '...';
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,163 @@
|
||||
// Route / path cipher (grid + reading route)
|
||||
import BaseTransformer from '../BaseTransformer.js';
|
||||
|
||||
export default (function() {
|
||||
function buildGrid(text, cols, pad) {
|
||||
const cleaned = text.replace(/\s/g, '').toUpperCase();
|
||||
const rows = Math.ceil(cleaned.length / cols);
|
||||
const grid = [];
|
||||
let idx = 0;
|
||||
for (let r = 0; r < rows; r++) {
|
||||
grid[r] = [];
|
||||
for (let c = 0; c < cols; c++) {
|
||||
grid[r][c] = idx < cleaned.length ? cleaned[idx++] : pad;
|
||||
}
|
||||
}
|
||||
return { grid, rows, cols, len: cleaned.length };
|
||||
}
|
||||
|
||||
function readSpiral(grid, rows, cols) {
|
||||
const out = [];
|
||||
let top = 0;
|
||||
let bottom = rows - 1;
|
||||
let left = 0;
|
||||
let right = cols - 1;
|
||||
while (top <= bottom && left <= right) {
|
||||
for (let c = left; c <= right; c++) out.push(grid[top][c]);
|
||||
top++;
|
||||
for (let r = top; r <= bottom; r++) out.push(grid[r][right]);
|
||||
right--;
|
||||
if (top <= bottom) {
|
||||
for (let c = right; c >= left; c--) out.push(grid[bottom][c]);
|
||||
bottom--;
|
||||
}
|
||||
if (left <= right) {
|
||||
for (let r = bottom; r >= top; r--) out.push(grid[r][left]);
|
||||
left++;
|
||||
}
|
||||
}
|
||||
return out.join('');
|
||||
}
|
||||
|
||||
function stripPad(text, pad) {
|
||||
const esc = pad.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
return text.replace(new RegExp(esc + '+$'), '');
|
||||
}
|
||||
|
||||
function writeSpiral(text, rows, cols, pad) {
|
||||
const grid = Array.from({ length: rows }, () => new Array(cols).fill(pad));
|
||||
let top = 0;
|
||||
let bottom = rows - 1;
|
||||
let left = 0;
|
||||
let right = cols - 1;
|
||||
let idx = 0;
|
||||
while (top <= bottom && left <= right && idx < text.length) {
|
||||
for (let c = left; c <= right && idx < text.length; c++) grid[top][c] = text[idx++];
|
||||
top++;
|
||||
for (let r = top; r <= bottom && idx < text.length; r++) grid[r][right] = text[idx++];
|
||||
right--;
|
||||
if (top <= bottom) {
|
||||
for (let c = right; c >= left && idx < text.length; c--) grid[bottom][c] = text[idx++];
|
||||
bottom--;
|
||||
}
|
||||
if (left <= right) {
|
||||
for (let r = bottom; r >= top && idx < text.length; r--) grid[r][left] = text[idx++];
|
||||
left++;
|
||||
}
|
||||
}
|
||||
return grid;
|
||||
}
|
||||
|
||||
return new BaseTransformer({
|
||||
name: 'Route Cipher',
|
||||
priority: 60,
|
||||
category: 'cipher',
|
||||
configurableOptions: [
|
||||
{ id: 'cols', label: 'Grid columns', type: 'number', default: 5, min: 2, max: 20, step: 1 },
|
||||
{
|
||||
id: 'route',
|
||||
label: 'Reading route',
|
||||
type: 'select',
|
||||
default: 'spiral',
|
||||
options: [
|
||||
{ value: 'spiral', label: 'Spiral (clockwise)' },
|
||||
{ value: 'rows', label: 'Row by row' },
|
||||
{ value: 'cols', label: 'Column by column' },
|
||||
{ value: 'snake', label: 'Snake rows' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'padChar',
|
||||
label: 'Padding character',
|
||||
type: 'text',
|
||||
default: 'X'
|
||||
}
|
||||
],
|
||||
_opts: function(options) {
|
||||
options = options || {};
|
||||
const cols = Math.max(2, Number(options.cols != null ? options.cols : 5) || 5);
|
||||
const route = options.route || 'spiral';
|
||||
const padChar = String(options.padChar != null ? options.padChar : 'X').charAt(0) || 'X';
|
||||
return { cols, route, padChar };
|
||||
},
|
||||
_readRoute: function(grid, rows, cols, route) {
|
||||
if (route === 'rows') return grid.flat().join('');
|
||||
if (route === 'cols') {
|
||||
let out = '';
|
||||
for (let c = 0; c < cols; c++) {
|
||||
for (let r = 0; r < rows; r++) out += grid[r][c];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
if (route === 'snake') {
|
||||
return grid.map((row, i) => (i % 2 ? [...row].reverse() : row).join('')).join('');
|
||||
}
|
||||
return readSpiral(grid, rows, cols);
|
||||
},
|
||||
func: function(text, options) {
|
||||
const { cols, route, padChar } = this._opts(options);
|
||||
const { grid, rows } = buildGrid(text, cols, padChar);
|
||||
const raw = this._readRoute(grid, rows, cols, route);
|
||||
return stripPad(raw, padChar);
|
||||
},
|
||||
reverse: function(text, options) {
|
||||
const { cols, route, padChar } = this._opts(options);
|
||||
const cleaned = text.replace(/\s/g, '').toUpperCase();
|
||||
if (!cleaned.length) return text;
|
||||
const rows = Math.ceil(cleaned.length / cols);
|
||||
if (route === 'spiral') {
|
||||
const grid = writeSpiral(cleaned, rows, cols, padChar);
|
||||
let out = '';
|
||||
for (let r = 0; r < rows; r++) {
|
||||
for (let c = 0; c < cols; c++) out += grid[r][c];
|
||||
}
|
||||
return stripPad(out, padChar);
|
||||
}
|
||||
if (route === 'rows') return cleaned;
|
||||
if (route === 'cols') {
|
||||
const grid = Array.from({ length: rows }, () => new Array(cols).fill(padChar));
|
||||
let idx = 0;
|
||||
for (let c = 0; c < cols; c++) {
|
||||
for (let r = 0; r < rows && idx < cleaned.length; r++) grid[r][c] = cleaned[idx++];
|
||||
}
|
||||
return stripPad(grid.flat().join(''), padChar);
|
||||
}
|
||||
if (route === 'snake') {
|
||||
const grid = Array.from({ length: rows }, () => new Array(cols).fill(padChar));
|
||||
let idx = 0;
|
||||
for (let r = 0; r < rows; r++) {
|
||||
const order = r % 2 ? [...Array(cols).keys()].reverse() : [...Array(cols).keys()];
|
||||
for (const c of order) {
|
||||
if (idx < cleaned.length) grid[r][c] = cleaned[idx++];
|
||||
}
|
||||
}
|
||||
return stripPad(grid.flat().join(''), padChar);
|
||||
}
|
||||
return cleaned;
|
||||
},
|
||||
preview: function(text, options) {
|
||||
if (!text) return '[route]';
|
||||
return this.func(text.slice(0, 10), options).slice(0, 12) + '...';
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,48 @@
|
||||
// Trithemius cipher (progressive Caesar / tabula recta rows)
|
||||
import BaseTransformer from '../BaseTransformer.js';
|
||||
|
||||
export default new BaseTransformer({
|
||||
name: 'Trithemius Cipher',
|
||||
priority: 60,
|
||||
category: 'cipher',
|
||||
configurableOptions: [
|
||||
{ id: 'startShift', label: 'Starting shift', type: 'number', default: 0, min: 0, max: 25, step: 1 },
|
||||
{ id: 'step', label: 'Shift increment per letter', type: 'number', default: 1, min: 1, max: 25, step: 1 }
|
||||
],
|
||||
_opts: function(options) {
|
||||
options = options || {};
|
||||
return {
|
||||
start: Number(options.startShift != null ? options.startShift : 0) || 0,
|
||||
step: Number(options.step != null ? options.step : 1) || 1
|
||||
};
|
||||
},
|
||||
_transform: function(text, options, decode) {
|
||||
const { start, step } = this._opts(options);
|
||||
let out = '';
|
||||
let letterIdx = 0;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const c = text[i];
|
||||
const code = c.charCodeAt(0);
|
||||
const shift = (start + letterIdx * step) % 26;
|
||||
const delta = decode ? (26 - shift) % 26 : shift;
|
||||
if (code >= 65 && code <= 90) {
|
||||
out += String.fromCharCode(((code - 65 + delta) % 26) + 65);
|
||||
letterIdx++;
|
||||
} else if (code >= 97 && code <= 122) {
|
||||
out += String.fromCharCode(((code - 97 + delta) % 26) + 97);
|
||||
letterIdx++;
|
||||
} else out += c;
|
||||
}
|
||||
return out;
|
||||
},
|
||||
func: function(text, options) {
|
||||
return this._transform(text, options, false);
|
||||
},
|
||||
reverse: function(text, options) {
|
||||
return this._transform(text, options, true);
|
||||
},
|
||||
preview: function(text, options) {
|
||||
if (!text) return '[trithemius]';
|
||||
return this.func(text.slice(0, 8), options) + '...';
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
// Vernam cipher (one-time pad style additive mod 26 with key)
|
||||
import BaseTransformer from '../BaseTransformer.js';
|
||||
|
||||
export default new BaseTransformer({
|
||||
name: 'Vernam Cipher',
|
||||
priority: 60,
|
||||
category: 'cipher',
|
||||
key: 'SECRET',
|
||||
configurableOptions: [
|
||||
{ id: 'key', label: 'Key (pad)', type: 'text', default: 'SECRET' }
|
||||
],
|
||||
_keyStr: function(options) {
|
||||
const k = options && options.key != null ? String(options.key) : null;
|
||||
return (k || this.key || 'SECRET').toUpperCase().replace(/[^A-Z]/g, '');
|
||||
},
|
||||
_apply: function(text, options, decode) {
|
||||
const key = this._keyStr(options);
|
||||
if (!key.length) return text;
|
||||
let out = '';
|
||||
let j = 0;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const c = text[i];
|
||||
const code = c.charCodeAt(0);
|
||||
const k = key[j % key.length].charCodeAt(0) - 65;
|
||||
if (code >= 65 && code <= 90) {
|
||||
out += String.fromCharCode(decode
|
||||
? ((code - 65 + 26 - k) % 26) + 65
|
||||
: ((code - 65 + k) % 26) + 65);
|
||||
j++;
|
||||
} else if (code >= 97 && code <= 122) {
|
||||
out += String.fromCharCode(decode
|
||||
? ((code - 97 + 26 - k) % 26) + 97
|
||||
: ((code - 97 + k) % 26) + 97);
|
||||
j++;
|
||||
} else out += c;
|
||||
}
|
||||
return out;
|
||||
},
|
||||
func: function(text, options) {
|
||||
return this._apply(text, options, false);
|
||||
},
|
||||
reverse: function(text, options) {
|
||||
return this._apply(text, options, true);
|
||||
},
|
||||
preview: function(text, options) {
|
||||
if (!text) return '[vernam]';
|
||||
return this.func(text.slice(0, 8), options) + '...';
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
// Acrostic — read or build messages from initial letters
|
||||
import BaseTransformer from '../BaseTransformer.js';
|
||||
|
||||
export default (function() {
|
||||
const TEMPLATES = [
|
||||
'{letter}idden words carry meaning beyond the surface.',
|
||||
'{letter}ach line begins with the secret letter.',
|
||||
'{letter}ften the first letter tells the story.',
|
||||
'{letter}essages can hide in plain sight.',
|
||||
'{letter}eaders who look closely will find clues.'
|
||||
];
|
||||
|
||||
function extractAcrostic(text, mode, includeSpecial) {
|
||||
if (mode === 'words') {
|
||||
if (includeSpecial) {
|
||||
const words = text.match(/\S+/g) || [];
|
||||
return words.map(function(word) {
|
||||
return word.charAt(0) || '';
|
||||
}).join('');
|
||||
}
|
||||
const words = text.match(/[a-zA-Z0-9']+/g) || [];
|
||||
return words.map(function(word) {
|
||||
return word.replace(/[^a-zA-Z0-9]/g, '')[0] || '';
|
||||
}).join('');
|
||||
}
|
||||
return text.split(/\r?\n/).map(function(line) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) {
|
||||
return '';
|
||||
}
|
||||
if (includeSpecial) {
|
||||
return trimmed.charAt(0);
|
||||
}
|
||||
const match = trimmed.match(/[a-zA-Z0-9]/);
|
||||
return match ? match[0] : '';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function normalizeSecret(text, includeSpecial) {
|
||||
if (includeSpecial) {
|
||||
return text.replace(/\s+/g, '');
|
||||
}
|
||||
return text.replace(/[^a-zA-Z0-9]/g, '');
|
||||
}
|
||||
|
||||
function formatAcrosticLine(letter, template) {
|
||||
const line = template.replace(/\{letter\}/g, letter);
|
||||
if (/[a-zA-Z]/.test(letter)) {
|
||||
return line.charAt(0).toUpperCase() + line.slice(1);
|
||||
}
|
||||
return line;
|
||||
}
|
||||
|
||||
return new BaseTransformer({
|
||||
name: 'Acrostic',
|
||||
priority: 46,
|
||||
category: 'concealment',
|
||||
description: 'Extract hidden messages from the first letter of each line or word, or build acrostic cover text.',
|
||||
configurableOptions: [
|
||||
{
|
||||
id: 'mode',
|
||||
label: 'Read letters from',
|
||||
type: 'select',
|
||||
default: 'lines',
|
||||
options: [
|
||||
{ value: 'lines', label: 'First letter of each line' },
|
||||
{ value: 'words', label: 'First letter of each word' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'includeSpecial',
|
||||
label: 'Include special characters',
|
||||
type: 'boolean',
|
||||
default: false
|
||||
},
|
||||
{
|
||||
id: 'lineTemplate',
|
||||
label: 'Custom line template ({letter} placeholder)',
|
||||
type: 'text',
|
||||
default: ''
|
||||
}
|
||||
],
|
||||
func: function(text, options) {
|
||||
options = options || {};
|
||||
const mode = options.mode === 'words' ? 'words' : 'lines';
|
||||
const includeSpecial = !!options.includeSpecial;
|
||||
const customTemplate = String(options.lineTemplate || '').trim();
|
||||
const secret = normalizeSecret(text, includeSpecial);
|
||||
if (!secret) {
|
||||
return text;
|
||||
}
|
||||
if (mode === 'words') {
|
||||
return secret.split('').join(' ');
|
||||
}
|
||||
return secret.split('').map(function(letter, index) {
|
||||
const template = customTemplate || TEMPLATES[index % TEMPLATES.length];
|
||||
return formatAcrosticLine(letter, template);
|
||||
}).join('\n');
|
||||
},
|
||||
reverse: function(text, options) {
|
||||
options = options || {};
|
||||
const mode = options.mode === 'words' ? 'words' : 'lines';
|
||||
const includeSpecial = !!options.includeSpecial;
|
||||
return extractAcrostic(text, mode, includeSpecial);
|
||||
},
|
||||
preview: function(text, options) {
|
||||
if (!text) {
|
||||
return '[acrostic]';
|
||||
}
|
||||
return this.func(text.slice(0, 5), options);
|
||||
},
|
||||
detector: function(text) {
|
||||
if (/\n/.test(text)) {
|
||||
const lines = text.split(/\r?\n/).filter(function(line) {
|
||||
return line.trim().length > 0;
|
||||
});
|
||||
return lines.length >= 2;
|
||||
}
|
||||
return (text.match(/[a-zA-Z0-9']+/g) || []).length >= 3;
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,208 @@
|
||||
// Cardan grille — write/read through a rotating hole template
|
||||
import BaseTransformer from '../BaseTransformer.js';
|
||||
|
||||
export default (function() {
|
||||
const DEFAULT_GRILLE = '#.#.\n.#.#\n#.#.\n.#.#';
|
||||
const DEFAULT_FILLER = 'abcdefghijklmnopqrstuvwxyz';
|
||||
|
||||
function parseGrille(pattern, size) {
|
||||
const rows = String(pattern || DEFAULT_GRILLE)
|
||||
.trim()
|
||||
.split(/\r?\n/)
|
||||
.map(function(row) { return row.trim(); })
|
||||
.filter(function(row) { return row.length > 0; });
|
||||
|
||||
const holes = [];
|
||||
for (let r = 0; r < rows.length && r < size; r++) {
|
||||
for (let c = 0; c < rows[r].length && c < size; c++) {
|
||||
const ch = rows[r][c];
|
||||
if (ch === '#' || ch === 'X' || ch === 'x' || ch === '1') {
|
||||
holes.push([r, c]);
|
||||
}
|
||||
}
|
||||
}
|
||||
return holes.sort(function(a, b) {
|
||||
return a[0] - b[0] || a[1] - b[1];
|
||||
});
|
||||
}
|
||||
|
||||
function rotateCoord(r, c, size) {
|
||||
return [c, size - 1 - r];
|
||||
}
|
||||
|
||||
function rotateHoles(holes, size) {
|
||||
return holes.map(function(pair) {
|
||||
return rotateCoord(pair[0], pair[1], size);
|
||||
}).sort(function(a, b) {
|
||||
return a[0] - b[0] || a[1] - b[1];
|
||||
});
|
||||
}
|
||||
|
||||
function makeGrid(size, filler) {
|
||||
const grid = [];
|
||||
for (let r = 0; r < size; r++) {
|
||||
grid.push(new Array(size).fill(''));
|
||||
}
|
||||
return grid;
|
||||
}
|
||||
|
||||
function flattenGrid(grid) {
|
||||
return grid.map(function(row) { return row.join(''); }).join('\n');
|
||||
}
|
||||
|
||||
function parseGrid(text, size) {
|
||||
const cleaned = text.replace(/[^a-zA-Z0-9]/g, '').toLowerCase();
|
||||
const grid = makeGrid(size);
|
||||
let index = 0;
|
||||
for (let r = 0; r < size; r++) {
|
||||
for (let c = 0; c < size; c++) {
|
||||
grid[r][c] = cleaned[index] || 'x';
|
||||
index++;
|
||||
}
|
||||
}
|
||||
return grid;
|
||||
}
|
||||
|
||||
function holeSets(baseHoles, size, rotations) {
|
||||
const sets = [baseHoles];
|
||||
let current = baseHoles;
|
||||
for (let i = 1; i < rotations; i++) {
|
||||
current = rotateHoles(current, size);
|
||||
sets.push(current);
|
||||
}
|
||||
return sets;
|
||||
}
|
||||
|
||||
function nextFillerChar(filler, index) {
|
||||
return filler[index % filler.length] || 'x';
|
||||
}
|
||||
|
||||
return new BaseTransformer({
|
||||
name: 'Cardan Grille',
|
||||
priority: 44,
|
||||
category: 'concealment',
|
||||
description: 'Hide text using a Cardan grille template with optional 90° rotations.',
|
||||
inputKind: 'textarea',
|
||||
configurableOptions: [
|
||||
{
|
||||
id: 'gridSize',
|
||||
label: 'Grid size',
|
||||
type: 'number',
|
||||
default: 4,
|
||||
min: 3,
|
||||
max: 8,
|
||||
step: 1
|
||||
},
|
||||
{
|
||||
id: 'rotations',
|
||||
label: 'Grille rotations',
|
||||
type: 'select',
|
||||
default: '4',
|
||||
options: [
|
||||
{ value: '1', label: '1 (single pass)' },
|
||||
{ value: '4', label: '4 (classic Cardan)' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'grille',
|
||||
label: 'Grille pattern (# = hole, . = cover)',
|
||||
type: 'text',
|
||||
default: DEFAULT_GRILLE
|
||||
},
|
||||
{
|
||||
id: 'filler',
|
||||
label: 'Decoy letters for empty cells',
|
||||
type: 'text',
|
||||
default: DEFAULT_FILLER
|
||||
},
|
||||
{
|
||||
id: 'outputFormat',
|
||||
label: 'Output layout',
|
||||
type: 'select',
|
||||
default: 'grid',
|
||||
options: [
|
||||
{ value: 'grid', label: 'Grid with newlines' },
|
||||
{ value: 'flat', label: 'Continuous string' }
|
||||
]
|
||||
}
|
||||
],
|
||||
func: function(text, options) {
|
||||
options = options || {};
|
||||
const size = Math.max(3, Math.min(8, parseInt(options.gridSize, 10) || 4));
|
||||
const rotations = Math.max(1, Math.min(4, parseInt(options.rotations, 10) || 4));
|
||||
const filler = String(options.filler || DEFAULT_FILLER).replace(/[^a-zA-Z0-9]/g, '').toLowerCase() || DEFAULT_FILLER;
|
||||
const message = text.replace(/[^a-zA-Z0-9]/g, '').toLowerCase();
|
||||
if (!message) {
|
||||
return text;
|
||||
}
|
||||
|
||||
const baseHoles = parseGrille(options.grille || DEFAULT_GRILLE, size);
|
||||
const sets = holeSets(baseHoles, size, rotations);
|
||||
const grid = makeGrid(size);
|
||||
let msgIndex = 0;
|
||||
let fillerIndex = 0;
|
||||
|
||||
sets.forEach(function(holes) {
|
||||
holes.forEach(function(pair) {
|
||||
const r = pair[0];
|
||||
const c = pair[1];
|
||||
if (!grid[r][c]) {
|
||||
grid[r][c] = message[msgIndex] || nextFillerChar(filler, fillerIndex++);
|
||||
if (message[msgIndex]) {
|
||||
msgIndex++;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
for (let r = 0; r < size; r++) {
|
||||
for (let c = 0; c < size; c++) {
|
||||
if (!grid[r][c]) {
|
||||
grid[r][c] = nextFillerChar(filler, fillerIndex++);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (options.outputFormat === 'flat') {
|
||||
return grid.map(function(row) { return row.join(''); }).join('');
|
||||
}
|
||||
return flattenGrid(grid);
|
||||
},
|
||||
reverse: function(text, options) {
|
||||
options = options || {};
|
||||
const size = Math.max(3, Math.min(8, parseInt(options.gridSize, 10) || 4));
|
||||
const rotations = Math.max(1, Math.min(4, parseInt(options.rotations, 10) || 4));
|
||||
const baseHoles = parseGrille(options.grille || DEFAULT_GRILLE, size);
|
||||
const sets = holeSets(baseHoles, size, rotations);
|
||||
const grid = parseGrid(text, size);
|
||||
let result = '';
|
||||
|
||||
sets.forEach(function(holes) {
|
||||
holes.forEach(function(pair) {
|
||||
result += grid[pair[0]][pair[1]] || '';
|
||||
});
|
||||
});
|
||||
|
||||
return result;
|
||||
},
|
||||
preview: function(text, options) {
|
||||
if (!text) {
|
||||
return '[cardan]';
|
||||
}
|
||||
return this.func(text.slice(0, 8), options);
|
||||
},
|
||||
detector: function(text) {
|
||||
const lines = text.trim().split(/\r?\n/);
|
||||
if (lines.length < 3) {
|
||||
return false;
|
||||
}
|
||||
const widths = lines.map(function(line) {
|
||||
return line.replace(/\s/g, '').length;
|
||||
});
|
||||
const first = widths[0];
|
||||
return first >= 3 && widths.every(function(width) {
|
||||
return width === first;
|
||||
});
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,34 @@
|
||||
// Homoglyph substitution — visually confusable Unicode characters
|
||||
import BaseTransformer from '../BaseTransformer.js';
|
||||
|
||||
export default (function() {
|
||||
const TO = {
|
||||
'A': '\u0410', 'B': '\u0412', 'C': '\u0421', 'E': '\u0415', 'H': '\u041D',
|
||||
'I': '\u0406', 'J': '\u0408', 'K': '\u041A', 'M': '\u041C', 'N': '\u041D',
|
||||
'O': '\u041E', 'P': '\u0420', 'S': '\u0405', 'T': '\u0422', 'X': '\u0425',
|
||||
'Y': '\u0423', 'a': '\u0430', 'c': '\u0441', 'e': '\u0435', 'i': '\u0456',
|
||||
'j': '\u0458', 'o': '\u043E', 'p': '\u0440', 's': '\u0455', 'x': '\u0445',
|
||||
'y': '\u0443'
|
||||
};
|
||||
const REV = {};
|
||||
for (const [k, v] of Object.entries(TO)) REV[v] = k;
|
||||
|
||||
return new BaseTransformer({
|
||||
name: 'Homoglyph Generator',
|
||||
priority: 70,
|
||||
category: 'unicode',
|
||||
func: function(text) {
|
||||
return [...text].map(c => TO[c] || c).join('');
|
||||
},
|
||||
reverse: function(text) {
|
||||
return [...text].map(c => REV[c] || c).join('');
|
||||
},
|
||||
preview: function(text) {
|
||||
if (!text) return '[homoglyph]';
|
||||
return this.func(text.slice(0, 12));
|
||||
},
|
||||
detector: function(text) {
|
||||
return /[\u0400-\u04FF]/.test(text) && /[A-Za-z]/.test(text) === false;
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,154 @@
|
||||
// Null cipher — hide/extract letters at a fixed position in each word
|
||||
import BaseTransformer from '../BaseTransformer.js';
|
||||
|
||||
export default (function() {
|
||||
function syntheticWord(letter, position) {
|
||||
const L = letter.toLowerCase();
|
||||
const pads = ['a', 'e', 'i', 'o', 'u', 'r', 's', 't', 'n', 'l'];
|
||||
let word = '';
|
||||
const len = Math.max(position + 1, 3);
|
||||
for (let i = 0; i < len; i++) {
|
||||
if (i === position - 1) {
|
||||
word += L;
|
||||
} else {
|
||||
word += pads[i % pads.length];
|
||||
}
|
||||
}
|
||||
return word;
|
||||
}
|
||||
|
||||
function pickCoverWord(letter, position, index, coverWords) {
|
||||
const target = letter.toLowerCase();
|
||||
const pos = position - 1;
|
||||
if (coverWords && coverWords.length) {
|
||||
for (let i = 0; i < coverWords.length; i++) {
|
||||
const word = coverWords[(index + i) % coverWords.length];
|
||||
const clean = word.replace(/[^a-zA-Z0-9]/g, '');
|
||||
if (clean[pos] && clean[pos].toLowerCase() === target) {
|
||||
return word;
|
||||
}
|
||||
}
|
||||
}
|
||||
return syntheticWord(letter, position);
|
||||
}
|
||||
|
||||
function normalizeSecret(text, includeSpecial) {
|
||||
if (includeSpecial) {
|
||||
return text.replace(/\s+/g, '');
|
||||
}
|
||||
return text.replace(/[^a-zA-Z0-9]/g, '');
|
||||
}
|
||||
|
||||
function extractNull(text, options) {
|
||||
options = options || {};
|
||||
const scope = options.scope === 'text' ? 'text' : 'words';
|
||||
const includeSpecial = !!options.includeSpecial;
|
||||
const position = Math.max(1, Math.min(10, parseInt(options.position, 10) || 2));
|
||||
|
||||
if (scope === 'text') {
|
||||
const source = includeSpecial ? text.replace(/\s/g, '') : text.replace(/[^a-zA-Z0-9]/g, '');
|
||||
let out = '';
|
||||
for (let i = position - 1; i < source.length; i += position) {
|
||||
out += source[i];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const pos = position - 1;
|
||||
if (includeSpecial) {
|
||||
const words = text.match(/\S+/g) || [];
|
||||
return words.map(function(word) {
|
||||
return word.charAt(pos) || '';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
const words = text.match(/[a-zA-Z0-9']+/g) || [];
|
||||
return words.map(function(word) {
|
||||
const clean = word.replace(/[^a-zA-Z0-9]/g, '');
|
||||
if (pos >= 0 && pos < clean.length) {
|
||||
return clean[pos];
|
||||
}
|
||||
return '';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
return new BaseTransformer({
|
||||
name: 'Null Cipher',
|
||||
priority: 45,
|
||||
category: 'concealment',
|
||||
description: 'Conceal a message in cover text by fixing one letter position in each word, or extract hidden letters.',
|
||||
configurableOptions: [
|
||||
{
|
||||
id: 'scope',
|
||||
label: 'Extraction scope',
|
||||
type: 'select',
|
||||
default: 'words',
|
||||
options: [
|
||||
{ value: 'words', label: 'Nth letter of each word' },
|
||||
{ value: 'text', label: 'Every Nth letter of full text' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'position',
|
||||
label: 'Letter position / interval (1 = first letter or every letter)',
|
||||
type: 'number',
|
||||
default: 2,
|
||||
min: 1,
|
||||
max: 10,
|
||||
step: 1
|
||||
},
|
||||
{
|
||||
id: 'includeSpecial',
|
||||
label: 'Include special characters',
|
||||
type: 'boolean',
|
||||
default: false
|
||||
},
|
||||
{
|
||||
id: 'coverText',
|
||||
label: 'Cover word pool (optional, space-separated)',
|
||||
type: 'text',
|
||||
default: ''
|
||||
},
|
||||
{
|
||||
id: 'wordSeparator',
|
||||
label: 'Word separator',
|
||||
type: 'select',
|
||||
default: ' ',
|
||||
options: [
|
||||
{ value: ' ', label: 'Space' },
|
||||
{ value: '\n', label: 'New line' },
|
||||
{ value: ', ', label: 'Comma' }
|
||||
]
|
||||
}
|
||||
],
|
||||
func: function(text, options) {
|
||||
options = options || {};
|
||||
const position = Math.max(1, Math.min(10, parseInt(options.position, 10) || 2));
|
||||
const sep = options.wordSeparator != null ? String(options.wordSeparator) : ' ';
|
||||
const includeSpecial = !!options.includeSpecial;
|
||||
const secret = normalizeSecret(text, includeSpecial);
|
||||
if (!secret) {
|
||||
return text;
|
||||
}
|
||||
const coverWords = String(options.coverText || '')
|
||||
.match(/[a-zA-Z0-9']+/g) || [];
|
||||
const words = secret.split('').map(function(ch, index) {
|
||||
return pickCoverWord(ch, position, index, coverWords);
|
||||
});
|
||||
return words.join(sep);
|
||||
},
|
||||
reverse: function(text, options) {
|
||||
return extractNull(text, options || {});
|
||||
},
|
||||
preview: function(text, options) {
|
||||
if (!text) {
|
||||
return '[null-cipher]';
|
||||
}
|
||||
return this.func(text.slice(0, 8), options) + '...';
|
||||
},
|
||||
detector: function(text) {
|
||||
const words = text.match(/[a-zA-Z0-9']+/g) || [];
|
||||
return words.length >= 3 && words.length <= 500;
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,133 @@
|
||||
// Trevanion cipher — extract letters N positions after punctuation (steganography)
|
||||
import BaseTransformer from '../BaseTransformer.js';
|
||||
|
||||
export default (function() {
|
||||
const DEFAULT_TRIGGERS = '.,!?;:\u2014';
|
||||
const DEFAULT_FILLER = 'the quick brown fox jumps over the lazy dog while a warm wind blew across the open field';
|
||||
|
||||
function isTrigger(ch, triggers) {
|
||||
return triggers.includes(ch);
|
||||
}
|
||||
|
||||
function extractLetter(text, start, offset, lettersOnly) {
|
||||
let count = 0;
|
||||
for (let i = start; i < text.length; i++) {
|
||||
const c = text[i];
|
||||
if (lettersOnly) {
|
||||
if (/[A-Za-z]/.test(c)) count++;
|
||||
} else if (!/\s/.test(c)) {
|
||||
count++;
|
||||
}
|
||||
if (count === offset) return c;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function postTriggerSnippet(letter, offset) {
|
||||
const pads = 'etaoinshrdlucmfwypvbgkqjxz';
|
||||
let out = '';
|
||||
for (let i = 1; i <= offset; i++) {
|
||||
if (i === offset) {
|
||||
out += letter;
|
||||
} else {
|
||||
out += pads[(letter.charCodeAt(0) + i * 3) % pads.length];
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function buildCover(secret, options) {
|
||||
const offset = Math.max(1, Number(options.offset) || 3);
|
||||
const triggers = options.triggers || DEFAULT_TRIGGERS;
|
||||
const trigger = triggers.charAt(0) || ',';
|
||||
const coverWords = String(options.fillerWords || DEFAULT_FILLER)
|
||||
.match(/[a-zA-Z']+/g) || DEFAULT_FILLER.split(/\s+/);
|
||||
let out = '';
|
||||
for (let si = 0; si < secret.length; si++) {
|
||||
if (si > 0) out += ' ';
|
||||
out += coverWords[si % coverWords.length];
|
||||
out += trigger;
|
||||
out += postTriggerSnippet(secret[si], offset);
|
||||
}
|
||||
return out.replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
return new BaseTransformer({
|
||||
name: 'Trevanion Cipher',
|
||||
priority: 75,
|
||||
category: 'concealment',
|
||||
description: 'Hide letters N positions after punctuation marks, or extract them from cover text.',
|
||||
configurableOptions: [
|
||||
{
|
||||
id: 'offset',
|
||||
label: 'Letters after each mark (N)',
|
||||
type: 'number',
|
||||
default: 3,
|
||||
min: 1,
|
||||
max: 20
|
||||
},
|
||||
{
|
||||
id: 'lettersOnly',
|
||||
label: 'Count letters only (ignore spaces/punct)',
|
||||
type: 'boolean',
|
||||
default: true
|
||||
},
|
||||
{
|
||||
id: 'triggers',
|
||||
label: 'Trigger punctuation characters',
|
||||
type: 'text',
|
||||
default: '.,!?;:'
|
||||
},
|
||||
{
|
||||
id: 'mode',
|
||||
label: 'Encode mode',
|
||||
type: 'select',
|
||||
default: 'embed',
|
||||
options: [
|
||||
{ value: 'embed', label: 'Build cover text around secret' },
|
||||
{ value: 'decode', label: 'Extract only (input is cover text)' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'fillerWords',
|
||||
label: 'Cover word pool (space-separated)',
|
||||
type: 'text',
|
||||
default: ''
|
||||
}
|
||||
],
|
||||
func: function(text, options) {
|
||||
options = options || {};
|
||||
if (options.mode === 'decode') return text;
|
||||
const secret = text.replace(/\s/g, '');
|
||||
if (!secret) return text;
|
||||
return buildCover(secret, options);
|
||||
},
|
||||
reverse: function(text, options) {
|
||||
options = options || {};
|
||||
const offset = Number(options.offset) || 3;
|
||||
const triggers = options.triggers || DEFAULT_TRIGGERS;
|
||||
const lettersOnly = options.lettersOnly !== false;
|
||||
let out = '';
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
if (isTrigger(text[i], triggers)) {
|
||||
const ch = extractLetter(text, i + 1, offset, lettersOnly);
|
||||
if (ch) out += ch;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
},
|
||||
preview: function(text, options) {
|
||||
if (!text) return '[trevanion]';
|
||||
const dec = this.reverse(text, options);
|
||||
return dec ? dec.slice(0, 16) + '...' : this.func(text.slice(0, 8), options).slice(0, 24) + '...';
|
||||
},
|
||||
detector: function(text) {
|
||||
const triggers = DEFAULT_TRIGGERS;
|
||||
let hits = 0;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
if (isTrigger(text[i], triggers)) hits++;
|
||||
}
|
||||
return hits >= 3;
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -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());
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
+194
-148
@@ -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));
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
// Bibi-binary (Boby Lapointe) — hex syllables for text via UTF-8 bytes
|
||||
import BaseTransformer from '../BaseTransformer.js';
|
||||
|
||||
export default (function() {
|
||||
const HEX_TO_BIBI = {
|
||||
'0': 'HO', '1': 'HA', '2': 'HE', '3': 'HI', '4': 'BO', '5': 'BA',
|
||||
'6': 'BE', '7': 'BI', '8': 'KO', '9': 'KA', 'a': 'KE', 'b': 'KI',
|
||||
'c': 'DO', 'd': 'DA', 'e': 'DE', 'f': 'DI'
|
||||
};
|
||||
const BIBI_TO_HEX = {};
|
||||
for (const [h, b] of Object.entries(HEX_TO_BIBI)) BIBI_TO_HEX[b.toUpperCase()] = h;
|
||||
|
||||
function parseBibiToken(t) {
|
||||
return BIBI_TO_HEX[t.toUpperCase()] || null;
|
||||
}
|
||||
|
||||
return new BaseTransformer({
|
||||
name: 'Bibi-binary Code',
|
||||
priority: 85,
|
||||
category: 'encoding',
|
||||
configurableOptions: [
|
||||
{
|
||||
id: 'separator',
|
||||
label: 'Separator between syllable pairs',
|
||||
type: 'select',
|
||||
default: 'space',
|
||||
options: [
|
||||
{ value: 'space', label: 'Space' },
|
||||
{ value: 'none', label: 'None' }
|
||||
]
|
||||
}
|
||||
],
|
||||
func: function(text, options) {
|
||||
options = options || {};
|
||||
const sep = options.separator === 'none' ? '' : ' ';
|
||||
const bytes = new TextEncoder().encode(text);
|
||||
const hex = Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
const pairs = [];
|
||||
for (let i = 0; i < hex.length; i += 2) {
|
||||
const pair = hex.slice(i, i + 2);
|
||||
pairs.push(HEX_TO_BIBI[pair[0]] + HEX_TO_BIBI[pair[1]]);
|
||||
}
|
||||
return pairs.join(sep);
|
||||
},
|
||||
reverse: function(text, options) {
|
||||
options = options || {};
|
||||
const tokens = options.separator === 'none'
|
||||
? text.match(/[A-Za-z]{4}/g) || []
|
||||
: text.trim().split(/\s+/);
|
||||
let hex = '';
|
||||
for (const tok of tokens) {
|
||||
if (tok.length !== 4) continue;
|
||||
const h1 = parseBibiToken(tok.slice(0, 2));
|
||||
const h2 = parseBibiToken(tok.slice(2, 4));
|
||||
if (h1 && h2) hex += h1 + h2;
|
||||
}
|
||||
if (!hex || hex.length % 2 !== 0) return text;
|
||||
const bytes = new Uint8Array(hex.match(/.{2}/g).map(h => parseInt(h, 16)));
|
||||
try {
|
||||
return new TextDecoder().decode(bytes);
|
||||
} catch (e) {
|
||||
return text;
|
||||
}
|
||||
},
|
||||
preview: function(text, options) {
|
||||
if (!text) return '[bibi]';
|
||||
return this.func(text.slice(0, 3), options);
|
||||
},
|
||||
detector: function(text) {
|
||||
const tokens = text.trim().split(/\s+/);
|
||||
return tokens.length >= 2 && tokens.every(t => /^[A-Za-z]{4}$/.test(t));
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,86 @@
|
||||
// Decabit code (10-pulse ripple control encoding, ASCII 0-126)
|
||||
import BaseTransformer from '../BaseTransformer.js';
|
||||
|
||||
export default (function() {
|
||||
// Patterns use + (positive pulse) and - (negative pulse); index = code value
|
||||
const PATTERNS = [
|
||||
'--+-+++-+-', '+--+++--+-', '+--++-+-+-', '+--+-++-+-', '----+++-++',
|
||||
'++--+++---', '++--++--+-', '++--+-+-+-', '++---++-+-', '---++++-+-',
|
||||
'+-+-+++---', '+-+-+-+-+-', '+-+--++-+-', '+---++-++-', '+---++--++',
|
||||
'--+++-++--', '---++-+++-', '+---+-++-+', '+--++--+-+', '+--++-+--+',
|
||||
'+-+++--+--', '+--+++-+--', '++--+-++--', '-+-++-++--', '+--++--++-',
|
||||
'+-+++-+---', '++-+--++--', '+-+-+-++--', '+--+-+++--', '+--+--++-+',
|
||||
'+-++-++---', '+-++-+-+--', '+-+-++-+--', '+---++++--', '+-+--+-++-',
|
||||
'+++--++---', '+++--+-+--', '+++---++--', '++---+++--', '--+-++++--',
|
||||
'++--++-+--', '-+-+-+-++-', '++----+++-', '+----+-+++', '++---+-+-+',
|
||||
'++-+-+-+--', '++-+-+--+-', '+++----++-', '++--+--++-', '+--+-+-++-',
|
||||
'++++----+-', '++-++---+-', '+-+++---+-', '-++++---+-', '+-+-+---++',
|
||||
'+++-++----', '+++-+-+---', '+-+-+--++-', '-++-+--++-', '+++-+----',
|
||||
'-+++-++---', '-+-+-++-+-', '++---++--+', '++-+--+--+', '++-+++----',
|
||||
'++++--+---', '+--++++---', '-+-++++---', '++-+--+-+-', '-++---+++-',
|
||||
'+---+-+++-', '--+-+-+++-', '+----++++-', '--+--++++-', '+++---+-+-',
|
||||
'+-++---++-', '+--+--+++-', '--++--+++-', '-+-+---+-++', '-+++--+-+-',
|
||||
'-+-++-+-+-', '-+++---++-', '-+-++--++-', '-+---++++-', '-++++--+--',
|
||||
'-++-++-+--', '--++++-+--', '--++-+++--', '--++-+-++-', '-++++----',
|
||||
'--++++--+-', '--++-++-+-', '+-++----++', '-+-+++--+-', '-++-+-+-+-',
|
||||
'-+--++-++-', '---+++-++-', '-+--+-+++-', '+---+++-+-', '-+--+++-+-',
|
||||
'+-+-++--+-', '+--++-++--', '++-++--+--', '+-++--++--', '+-+--+++--',
|
||||
'-++--+++--', '++---+-++-', '++-+---++-', '+++-+---+-', '+++-+--+--',
|
||||
'++-+-++---', '++-++-+---', '+-+---+++-', '+-++--+-+-', '-+-+--+++-',
|
||||
'-+++-+-+--', '+-++-+--+-', '-++-+++---', '+++--+--+-', '+++++-----',
|
||||
'-+++++----', '--+++++---', '---+++++--', '----+++++-', '++++++++++'
|
||||
];
|
||||
const TO_CODE = {};
|
||||
const TO_CHAR = {};
|
||||
for (let i = 0; i < PATTERNS.length; i++) {
|
||||
TO_CODE[i] = PATTERNS[i];
|
||||
TO_CHAR[PATTERNS[i]] = String.fromCharCode(i);
|
||||
}
|
||||
|
||||
function normalizePattern(raw) {
|
||||
return raw.replace(/[+]/g, '+').replace(/[-−–—]/g, '-').replace(/\s/g, '');
|
||||
}
|
||||
|
||||
return new BaseTransformer({
|
||||
name: 'Decabit Code',
|
||||
priority: 90,
|
||||
category: 'electronics',
|
||||
configurableOptions: [
|
||||
{
|
||||
id: 'groupSeparator',
|
||||
label: 'Separator between pulse groups',
|
||||
type: 'select',
|
||||
default: 'space',
|
||||
options: [
|
||||
{ value: 'space', label: 'Space' },
|
||||
{ value: 'newline', label: 'New line' },
|
||||
{ value: 'none', label: 'None' }
|
||||
]
|
||||
}
|
||||
],
|
||||
func: function(text, options) {
|
||||
options = options || {};
|
||||
const sep = options.groupSeparator === 'newline' ? '\n'
|
||||
: (options.groupSeparator === 'none' ? '' : ' ');
|
||||
return [...text].map(c => TO_CODE[c.charCodeAt(0)] || TO_CODE[63]).join(sep);
|
||||
},
|
||||
reverse: function(text, options) {
|
||||
options = options || {};
|
||||
const splitRe = options.groupSeparator === 'newline' ? /\n+/
|
||||
: (options.groupSeparator === 'none' ? /(?=[+-]{10})/ : /\s+/);
|
||||
const groups = text.trim().split(splitRe).filter(Boolean);
|
||||
return groups.map(g => {
|
||||
const pat = normalizePattern(g);
|
||||
return TO_CHAR[pat] || '';
|
||||
}).join('');
|
||||
},
|
||||
preview: function(text, options) {
|
||||
if (!text) return '[decabit]';
|
||||
return this.func(text.slice(0, 3), options) + '...';
|
||||
},
|
||||
detector: function(text) {
|
||||
const groups = text.trim().split(/\s+/);
|
||||
return groups.length >= 2 && groups.every(g => /^[+-]{10}$/.test(normalizePattern(g)));
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -1,79 +1,113 @@
|
||||
// emoji encoding transform
|
||||
// Base256Emoji — multiformats/multibase (1 byte ↔ 1 emoji, lossless)
|
||||
// https://github.com/multiformats/multibase/blob/master/rfcs/Base256Emoji.md
|
||||
import BaseTransformer from '../BaseTransformer.js';
|
||||
|
||||
export default new BaseTransformer({
|
||||
name: 'Emoji Encoding',
|
||||
priority: 250,
|
||||
category: 'encoding',
|
||||
// Map bytes to emoji (using common emojis)
|
||||
emojiMap: [
|
||||
'😀', '😃', '😄', '😁', '😆', '😅', '🤣', '😂', '🙂', '🙃',
|
||||
'😉', '😊', '😇', '🥰', '😍', '🤩', '😘', '😗', '😚', '😙',
|
||||
'😋', '😛', '😜', '🤪', '😝', '🤑', '🤗', '🤭', '🤫', '🤔',
|
||||
'🤐', '🤨', '😐', '😑', '😶', '😏', '😒', '🙄', '😬', '🤥',
|
||||
'😌', '😔', '😪', '🤤', '😴', '😷', '🤒', '🤕', '🤢', '🤮',
|
||||
'🤧', '🥵', '🥶', '😶🌫️', '😵', '😵💫', '🤯', '🤠', '🥳', '😎',
|
||||
'🤓', '🧐', '😕', '😟', '🙁', '😮', '😯', '😲', '😳', '🥺',
|
||||
'😦', '😧', '😨', '😰', '😥', '😢', '😭', '😱', '😖', '😣',
|
||||
'😞', '😓', '😩', '😫', '🥱', '😤', '😡', '😠', '🤬', '😈',
|
||||
'👿', '💀', '☠️', '💩', '🤡', '👹', '👺', '👻', '👽', '👾',
|
||||
'🤖', '😺', '😸', '😹', '😻', '😼', '😽', '🙀', '😿', '😾',
|
||||
'🙈', '🙉', '🙊', '💋', '💌', '💘', '💝', '💖', '💗', '💓',
|
||||
'💞', '💕', '💟', '❣️', '💔', '❤️', '🧡', '💛', '💚', '💙',
|
||||
'💜', '🖤', '🤍', '🤎', '💯', '💢', '💥', '💫', '💦', '💨',
|
||||
'🕳️', '💣', '💬', '👁️🗨️', '🗨️', '🗯️', '💭', '💤', '👋', '🤚',
|
||||
'🖐️', '✋', '🖖', '👌', '🤌', '🤏', '✌️', '🤞', '🤟', '🤘',
|
||||
'🤙', '👈', '👉', '👆', '🖕', '👇', '☝️', '👍', '👎', '✊',
|
||||
'👊', '🤛', '🤜', '👏', '🙌', '👐', '🤲', '🤝', '🙏', '✍️',
|
||||
'💪', '🦾', '🦿', '🦵', '🦶', '👂', '🦻', '👃', '🧠', '🫀',
|
||||
'🫁', '🦷', '🦴', '👀', '👁️', '👅', '👄', '💋', '🩸', '👶',
|
||||
'🧒', '👦', '👧', '🧑', '👱', '👨', '🧔', '👨🦰', '👨🦱', '👨🦳',
|
||||
'👨🦲', '👩', '👩🦰', '👩🦱', '👩🦳', '👩🦲', '🧓', '👴', '👵', '🙍',
|
||||
'🙎', '🙅', '🙆', '💁', '🙋', '🧏', '🤦', '🤦♂️', '🤦♀️', '🤷',
|
||||
'🤷♂️', '🤷♀️', '🙇', '🙇♂️', '🙇♀️', '🤦', '🤦♂️', '🤦♀️', '🤷', '🤷♂️'
|
||||
],
|
||||
func: function(text) {
|
||||
const bytes = new TextEncoder().encode(text);
|
||||
let result = '';
|
||||
|
||||
for (const byte of bytes) {
|
||||
result += this.emojiMap[byte % this.emojiMap.length] + ' ';
|
||||
}
|
||||
|
||||
return result.trim();
|
||||
},
|
||||
reverse: function(text) {
|
||||
// Create reverse map
|
||||
const reverseMap = {};
|
||||
for (let i = 0; i < this.emojiMap.length; i++) {
|
||||
reverseMap[this.emojiMap[i]] = i;
|
||||
}
|
||||
|
||||
// Extract emojis (match any emoji, not just specific range)
|
||||
const emojis = text.match(/[\u{1F300}-\u{1F9FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}]/gu) || [];
|
||||
const bytes = [];
|
||||
|
||||
for (const emoji of emojis) {
|
||||
if (reverseMap[emoji] !== undefined) {
|
||||
bytes.push(reverseMap[emoji]);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return new TextDecoder().decode(new Uint8Array(bytes));
|
||||
} catch (e) {
|
||||
return '';
|
||||
}
|
||||
},
|
||||
preview: function(text) {
|
||||
if (!text) return '[emoji-encoding]';
|
||||
return this.func(text.slice(0, 3));
|
||||
},
|
||||
detector: function(text) {
|
||||
// Check for emoji patterns (broader range)
|
||||
const emojiPattern = /[\u{1F300}-\u{1F9FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}]/gu;
|
||||
const matches = text.match(emojiPattern) || [];
|
||||
return matches.length >= 3;
|
||||
}
|
||||
});
|
||||
export default (function() {
|
||||
// Official 256-rune table (go-multibase / base256emoji crate)
|
||||
const ALPHABET = '🚀🪐☄🛰🌌🌑🌒🌓🌔🌕🌖🌗🌘🌍🌏🌎🐉☀💻🖥💾💿😂❤😍🤣😊🙏💕😭😘👍😅👏😁🔥🥰💔💖💙😢🤔😆🙄💪😉☺👌🤗💜😔😎😇🌹🤦🎉💞✌✨🤷😱😌🌸🙌😋💗💚😏💛🙂💓🤩😄😀🖤😃💯🙈👇🎶😒🤭❣😜💋👀😪😑💥🙋😞😩😡🤪👊🥳😥🤤👉💃😳✋😚😝😴🌟😬🙃🍀🌷😻😓⭐✅🥺🌈😈🤘💦✔😣🏃💐☹🎊💘😠☝😕🌺🎂🌻😐🖕💝🙊😹🗣💫💀👑🎵🤞😛🔴😤🌼😫⚽🤙☕🏆🤫👈😮🙆🍻🍃🐶💁😲🌿🧡🎁⚡🌞🎈❌✊👋😰🤨😶🤝🚶💰🍓💢🤟🙁🚨💨🤬✈🎀🍺🤓😙💟🌱😖👶🥴▶➡❓💎💸⬇😨🌚🦋😷🕺⚠🙅😟😵👎🤲🤠🤧📌🔵💅🧐🐾🍒😗🤑🌊🤯🐷☎💧😯💆👆🎤🙇🍑❄🌴💣🐸💌📍🥀🤢👅💡💩👐📸👻🤐🤮🎼🥵🚩🍎🍊👼💍📣🥂';
|
||||
const TABLE = [...ALPHABET];
|
||||
const MULTIBASE_PREFIX = TABLE[0]; // U+1F680 🚀 — multibase code for this encoding
|
||||
|
||||
if (TABLE.length !== 256) {
|
||||
throw new Error('Base256Emoji alphabet must contain exactly 256 codepoints');
|
||||
}
|
||||
|
||||
const REVERSE = new Map(TABLE.map(function(emoji, index) {
|
||||
return [emoji, index];
|
||||
}));
|
||||
|
||||
function encodeBytes(bytes, multibase) {
|
||||
const emojis = Array.from(bytes, function(byte) {
|
||||
return TABLE[byte];
|
||||
});
|
||||
if (multibase) {
|
||||
return MULTIBASE_PREFIX + emojis.join('');
|
||||
}
|
||||
return emojis.join('');
|
||||
}
|
||||
|
||||
function decodeToBytes(text, multibase) {
|
||||
let runes = [...String(text).replace(/\s/g, '')];
|
||||
if (multibase && runes[0] === MULTIBASE_PREFIX) {
|
||||
runes = runes.slice(1);
|
||||
}
|
||||
if (runes.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const bytes = new Uint8Array(runes.length);
|
||||
for (let i = 0; i < runes.length; i++) {
|
||||
const value = REVERSE.get(runes[i]);
|
||||
if (value === undefined) {
|
||||
return null;
|
||||
}
|
||||
bytes[i] = value;
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
return new BaseTransformer({
|
||||
name: 'Base256Emoji',
|
||||
priority: 265,
|
||||
category: 'encoding',
|
||||
description: 'Multiformats Base256Emoji encoding (multibase): each byte maps to one emoji from the standard 256-codepoint alphabet.',
|
||||
configurableOptions: [
|
||||
{
|
||||
key: 'multibase',
|
||||
label: 'Multibase prefix',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
help: 'Prepend the 🚀 multibase code on encode; strip it on decode (interoperable with IPFS/multibase tools).'
|
||||
}
|
||||
],
|
||||
func: function(text, options) {
|
||||
if (!text) {
|
||||
return '';
|
||||
}
|
||||
const multibase = !!(options && options.multibase);
|
||||
const bytes = new TextEncoder().encode(text);
|
||||
return encodeBytes(bytes, multibase);
|
||||
},
|
||||
reverse: function(text, options) {
|
||||
if (!text) {
|
||||
return '';
|
||||
}
|
||||
const multibase = !!(options && options.multibase);
|
||||
let bytes = decodeToBytes(text, multibase);
|
||||
if (!bytes && !multibase) {
|
||||
bytes = decodeToBytes(text, true);
|
||||
}
|
||||
if (!bytes) {
|
||||
return '';
|
||||
}
|
||||
try {
|
||||
return new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
||||
} catch (e) {
|
||||
return '';
|
||||
}
|
||||
},
|
||||
preview: function(text, options) {
|
||||
if (!text) {
|
||||
return '[base256emoji]';
|
||||
}
|
||||
return this.func(text.slice(0, 4), options);
|
||||
},
|
||||
detector: function(text) {
|
||||
const runes = [...String(text).replace(/\s/g, '')];
|
||||
if (runes.length < 3) {
|
||||
return false;
|
||||
}
|
||||
let start = 0;
|
||||
if (runes[0] === MULTIBASE_PREFIX && runes.length > 3) {
|
||||
start = 1;
|
||||
}
|
||||
let hits = 0;
|
||||
for (let i = start; i < runes.length; i++) {
|
||||
if (REVERSE.has(runes[i])) {
|
||||
hits++;
|
||||
}
|
||||
}
|
||||
const checked = runes.length - start;
|
||||
return checked >= 3 && hits / checked >= 0.85;
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
// Manchester line code (IEEE 802.3: 0=10, 1=01)
|
||||
import BaseTransformer from '../BaseTransformer.js';
|
||||
|
||||
export default (function() {
|
||||
function toBits(text) {
|
||||
const bytes = new TextEncoder().encode(text);
|
||||
return Array.from(bytes).map(b => b.toString(2).padStart(8, '0')).join('');
|
||||
}
|
||||
|
||||
function fromBits(bits) {
|
||||
const bytes = [];
|
||||
for (let i = 0; i + 8 <= bits.length; i += 8) {
|
||||
bytes.push(parseInt(bits.slice(i, i + 8), 2));
|
||||
}
|
||||
try {
|
||||
return new TextDecoder().decode(new Uint8Array(bytes));
|
||||
} catch (e) {
|
||||
return bits;
|
||||
}
|
||||
}
|
||||
|
||||
return new BaseTransformer({
|
||||
name: 'Manchester Code',
|
||||
priority: 95,
|
||||
category: 'technical',
|
||||
configurableOptions: [
|
||||
{
|
||||
id: 'spacing',
|
||||
label: 'Space between bit pairs',
|
||||
type: 'boolean',
|
||||
default: true
|
||||
}
|
||||
],
|
||||
func: function(text, options) {
|
||||
options = options || {};
|
||||
const pairs = [...toBits(text)].map(b => (b === '0' ? '10' : '01'));
|
||||
return options.spacing ? pairs.join(' ') : pairs.join('');
|
||||
},
|
||||
reverse: function(text, options) {
|
||||
options = options || {};
|
||||
const cleaned = text.replace(/\s/g, '');
|
||||
if (!/^[01]+$/.test(cleaned) || cleaned.length % 2 !== 0) return text;
|
||||
let bits = '';
|
||||
for (let i = 0; i < cleaned.length; i += 2) {
|
||||
const pair = cleaned.slice(i, i + 2);
|
||||
if (pair === '10') bits += '0';
|
||||
else if (pair === '01') bits += '1';
|
||||
else return text;
|
||||
}
|
||||
return fromBits(bits);
|
||||
},
|
||||
preview: function(text, options) {
|
||||
if (!text) return '[manchester]';
|
||||
const out = this.func(text.slice(0, 2), options);
|
||||
return out.length > 24 ? out.slice(0, 24) + '...' : out;
|
||||
},
|
||||
detector: function(text) {
|
||||
const cleaned = text.replace(/\s/g, '');
|
||||
return cleaned.length >= 16 && /^[01]+$/.test(cleaned) && cleaned.length % 2 === 0
|
||||
&& /^((10|01)+)$/.test(cleaned);
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,214 @@
|
||||
// Metaphone phonetic encoding
|
||||
import BaseTransformer from '../BaseTransformer.js';
|
||||
|
||||
export default (function() {
|
||||
function metaphoneWord(word) {
|
||||
let w = String(word).toUpperCase().replace(/[^A-Z]/g, '');
|
||||
if (!w) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const first = w.charAt(0);
|
||||
if (/^[AEIOU]/.test(w)) {
|
||||
w = w.replace(/^([AEIOU])/, 'A');
|
||||
} else if (w.startsWith('KN') || w.startsWith('GN') || w.startsWith('PN') || w.startsWith('WR') || w.startsWith('AE')) {
|
||||
w = w.slice(1);
|
||||
}
|
||||
|
||||
let out = first === 'A' && /^[AEIOU]/.test(String(word).toUpperCase()) ? 'A' : first;
|
||||
let i = 1;
|
||||
|
||||
while (i < w.length && out.length < 4) {
|
||||
const ch = w.charAt(i);
|
||||
const next = w.charAt(i + 1);
|
||||
const prev = w.charAt(i - 1);
|
||||
|
||||
if ('AEIOU'.indexOf(ch) >= 0) {
|
||||
if (i === 0 || prev === 'A') {
|
||||
out += 'A';
|
||||
}
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === 'B') {
|
||||
out += 'P';
|
||||
i += next === 'B' ? 2 : 1;
|
||||
continue;
|
||||
}
|
||||
if (ch === 'C') {
|
||||
if (next === 'I' || next === 'E' || next === 'Y') {
|
||||
out += 'S';
|
||||
} else {
|
||||
out += 'K';
|
||||
}
|
||||
i += next === 'H' && (next === 'I' || next === 'E' || next === 'Y') ? 2 : 1;
|
||||
continue;
|
||||
}
|
||||
if (ch === 'D') {
|
||||
out += next === 'G' && 'EIY'.indexOf(w.charAt(i + 2)) >= 0 ? 'J' : 'T';
|
||||
i += next === 'G' ? 3 : 1;
|
||||
continue;
|
||||
}
|
||||
if (ch === 'F') {
|
||||
out += 'F';
|
||||
i += next === 'F' ? 2 : 1;
|
||||
continue;
|
||||
}
|
||||
if (ch === 'G') {
|
||||
if (next === 'H' && 'EIY'.indexOf(w.charAt(i + 2)) < 0) {
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if (next === 'N' && w.charAt(i + 2) === 'E' && w.charAt(i + 3) === 'D') {
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
out += 'K';
|
||||
i += next === 'G' ? 2 : 1;
|
||||
continue;
|
||||
}
|
||||
if (ch === 'H') {
|
||||
if ('AEIOU'.indexOf(prev) >= 0 && 'AEIOU'.indexOf(next) >= 0) {
|
||||
out += 'H';
|
||||
}
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (ch === 'J') {
|
||||
out += 'J';
|
||||
i += next === 'J' ? 2 : 1;
|
||||
continue;
|
||||
}
|
||||
if (ch === 'K') {
|
||||
out += 'K';
|
||||
i += next === 'K' ? 2 : 1;
|
||||
continue;
|
||||
}
|
||||
if (ch === 'L') {
|
||||
out += 'L';
|
||||
i += next === 'L' ? 2 : 1;
|
||||
continue;
|
||||
}
|
||||
if (ch === 'M') {
|
||||
out += 'M';
|
||||
i += next === 'M' ? 2 : 1;
|
||||
continue;
|
||||
}
|
||||
if (ch === 'N') {
|
||||
out += 'N';
|
||||
i += next === 'N' ? 2 : 1;
|
||||
continue;
|
||||
}
|
||||
if (ch === 'P') {
|
||||
out += next === 'H' ? 'F' : 'P';
|
||||
i += next === 'H' ? 2 : next === 'P' ? 2 : 1;
|
||||
continue;
|
||||
}
|
||||
if (ch === 'Q') {
|
||||
out += 'K';
|
||||
i += next === 'Q' ? 2 : 1;
|
||||
continue;
|
||||
}
|
||||
if (ch === 'R') {
|
||||
out += 'R';
|
||||
i += next === 'R' ? 2 : 1;
|
||||
continue;
|
||||
}
|
||||
if (ch === 'S') {
|
||||
out += next === 'H' ? 'X' : 'S';
|
||||
i += next === 'H' ? 2 : next === 'S' ? 2 : 1;
|
||||
continue;
|
||||
}
|
||||
if (ch === 'T') {
|
||||
if (next === 'I' && 'AO'.indexOf(w.charAt(i + 2)) >= 0) {
|
||||
out += 'X';
|
||||
} else if (next === 'H') {
|
||||
out += '0';
|
||||
} else if (next === 'C' && w.charAt(i + 2) === 'H') {
|
||||
out += 'X';
|
||||
} else {
|
||||
out += 'T';
|
||||
}
|
||||
i += next === 'H' || (next === 'C' && w.charAt(i + 2) === 'H') ? 2 : next === 'T' ? 2 : 1;
|
||||
continue;
|
||||
}
|
||||
if (ch === 'V') {
|
||||
out += 'F';
|
||||
i += next === 'V' ? 2 : 1;
|
||||
continue;
|
||||
}
|
||||
if (ch === 'W') {
|
||||
if ('AEIOU'.indexOf(next) >= 0) {
|
||||
out += 'W';
|
||||
}
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (ch === 'X') {
|
||||
out += 'KS';
|
||||
i += next === 'X' ? 2 : 1;
|
||||
continue;
|
||||
}
|
||||
if (ch === 'Y') {
|
||||
if ('AEIOU'.indexOf(next) >= 0) {
|
||||
out += 'Y';
|
||||
}
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (ch === 'Z') {
|
||||
out += 'S';
|
||||
i += next === 'Z' ? 2 : 1;
|
||||
continue;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
|
||||
return out.slice(0, 4);
|
||||
}
|
||||
|
||||
return new BaseTransformer({
|
||||
name: 'Metaphone',
|
||||
priority: 50,
|
||||
category: 'format',
|
||||
canDecode: false,
|
||||
description: 'Phonetic encoding for English words (Metaphone algorithm).',
|
||||
configurableOptions: [
|
||||
{
|
||||
id: 'separator',
|
||||
label: 'Word separator',
|
||||
type: 'select',
|
||||
default: ' ',
|
||||
options: [
|
||||
{ value: ' ', label: 'Space' },
|
||||
{ value: '-', label: 'Dash' },
|
||||
{ value: ',', label: 'Comma' },
|
||||
{ value: '', label: 'None' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'uppercase',
|
||||
label: 'Uppercase output',
|
||||
type: 'boolean',
|
||||
default: true
|
||||
}
|
||||
],
|
||||
func: function(text, options) {
|
||||
options = options || {};
|
||||
const separator = options.separator != null ? String(options.separator) : ' ';
|
||||
const uppercase = options.uppercase !== false;
|
||||
const encoded = text.split(/\s+/).map(function(word) {
|
||||
return metaphoneWord(word);
|
||||
}).filter(Boolean);
|
||||
let out = encoded.join(separator);
|
||||
return uppercase ? out.toUpperCase() : out.toLowerCase();
|
||||
},
|
||||
preview: function(text, options) {
|
||||
if (!text) {
|
||||
return '[metaphone]';
|
||||
}
|
||||
return this.func(text.slice(0, 30), options);
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,77 @@
|
||||
// Shadoks numeral system — GA BU ZO MEU (base 4) for text via UTF-8 bytes
|
||||
import BaseTransformer from '../BaseTransformer.js';
|
||||
|
||||
export default (function() {
|
||||
const DIGITS = ['GA', 'BU', 'ZO', 'MEU'];
|
||||
const REV = { GA: 0, BU: 1, ZO: 2, MEU: 3 };
|
||||
|
||||
function toShadoks(n) {
|
||||
if (n === 0) return 'GA';
|
||||
let x = n;
|
||||
let out = '';
|
||||
while (x > 0) {
|
||||
out = DIGITS[x % 4] + (out ? ' ' + out : '');
|
||||
x = Math.floor(x / 4);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function fromShadoks(tokens) {
|
||||
let n = 0;
|
||||
for (const t of tokens) {
|
||||
const v = REV[t.toUpperCase()];
|
||||
if (v === undefined) return null;
|
||||
n = n * 4 + v;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
return new BaseTransformer({
|
||||
name: 'Shadoks Numeral System',
|
||||
priority: 82,
|
||||
category: 'encoding',
|
||||
configurableOptions: [
|
||||
{
|
||||
id: 'separator',
|
||||
label: 'Separator between bytes',
|
||||
type: 'select',
|
||||
default: 'pipe',
|
||||
options: [
|
||||
{ value: 'pipe', label: 'Pipe (|)' },
|
||||
{ value: 'space', label: 'Space' }
|
||||
]
|
||||
}
|
||||
],
|
||||
func: function(text, options) {
|
||||
options = options || {};
|
||||
const sep = options.separator === 'space' ? ' ' : ' | ';
|
||||
const bytes = new TextEncoder().encode(text);
|
||||
return Array.from(bytes).map(b => toShadoks(b)).join(sep);
|
||||
},
|
||||
reverse: function(text, options) {
|
||||
options = options || {};
|
||||
const groups = options.separator === 'space'
|
||||
? text.trim().split(/\s{2,}|\|/)
|
||||
: text.split('|');
|
||||
const bytes = [];
|
||||
for (const g of groups) {
|
||||
const tokens = g.trim().split(/\s+/).filter(Boolean);
|
||||
const n = fromShadoks(tokens);
|
||||
if (n === null || n > 255) return text;
|
||||
bytes.push(n);
|
||||
}
|
||||
try {
|
||||
return new TextDecoder().decode(new Uint8Array(bytes));
|
||||
} catch (e) {
|
||||
return text;
|
||||
}
|
||||
},
|
||||
preview: function(text, options) {
|
||||
if (!text) return '[shadok]';
|
||||
return this.func(text.slice(0, 2), options);
|
||||
},
|
||||
detector: function(text) {
|
||||
return /\b(GA|BU|ZO|MEU)(\s+(GA|BU|ZO|MEU))+\b/i.test(text);
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,71 @@
|
||||
// Split text into fixed-size letter groups
|
||||
import BaseTransformer from '../BaseTransformer.js';
|
||||
|
||||
export default new BaseTransformer({
|
||||
name: 'Group Letters',
|
||||
priority: 50,
|
||||
category: 'format',
|
||||
description: 'Split letters into fixed-size groups or rejoin grouped text.',
|
||||
configurableOptions: [
|
||||
{
|
||||
id: 'groupSize',
|
||||
label: 'Letters per group',
|
||||
type: 'number',
|
||||
default: 5,
|
||||
min: 1,
|
||||
max: 32,
|
||||
step: 1
|
||||
},
|
||||
{
|
||||
id: 'separator',
|
||||
label: 'Group separator',
|
||||
type: 'select',
|
||||
default: ' ',
|
||||
options: [
|
||||
{ value: ' ', label: 'Space' },
|
||||
{ value: '-', label: 'Dash' },
|
||||
{ value: '.', label: 'Dot' },
|
||||
{ value: '\n', label: 'New line' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'lettersOnly',
|
||||
label: 'Letters only (ignore spaces/punctuation)',
|
||||
type: 'boolean',
|
||||
default: true
|
||||
}
|
||||
],
|
||||
func: function(text, options) {
|
||||
options = options || {};
|
||||
const size = Math.max(1, Math.min(32, parseInt(options.groupSize, 10) || 5));
|
||||
const sep = options.separator != null ? String(options.separator) : ' ';
|
||||
const lettersOnly = options.lettersOnly !== false;
|
||||
const source = lettersOnly ? text.replace(/[^a-zA-Z]/g, '') : text.replace(/\s+/g, '');
|
||||
if (!source) {
|
||||
return text;
|
||||
}
|
||||
const groups = [];
|
||||
for (let i = 0; i < source.length; i += size) {
|
||||
groups.push(source.slice(i, i + size));
|
||||
}
|
||||
return groups.join(sep);
|
||||
},
|
||||
reverse: function(text, options) {
|
||||
options = options || {};
|
||||
const sep = options.separator != null ? String(options.separator) : ' ';
|
||||
if (sep === ' ') {
|
||||
return text.replace(/\s+/g, '');
|
||||
}
|
||||
const esc = sep.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
return text.split(new RegExp(esc.length ? esc : '\\s+')).join('');
|
||||
},
|
||||
preview: function(text, options) {
|
||||
if (!text) {
|
||||
return '[group-letters]';
|
||||
}
|
||||
return this.func(text.slice(0, 20), options);
|
||||
},
|
||||
detector: function(text) {
|
||||
return /\b[a-zA-Z]{1,8}(?:[ \-.][a-zA-Z]{1,8}){2,}\b/.test(text);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
// Pad or strip leading zeros on numeric tokens
|
||||
import BaseTransformer from '../BaseTransformer.js';
|
||||
|
||||
export default new BaseTransformer({
|
||||
name: 'Leading Zeros',
|
||||
priority: 50,
|
||||
category: 'format',
|
||||
description: 'Pad numbers with leading zeros or strip them from numeric tokens.',
|
||||
configurableOptions: [
|
||||
{
|
||||
id: 'mode',
|
||||
label: 'Mode',
|
||||
type: 'select',
|
||||
default: 'pad',
|
||||
options: [
|
||||
{ value: 'pad', label: 'Pad to width' },
|
||||
{ value: 'strip', label: 'Strip leading zeros' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'width',
|
||||
label: 'Pad width',
|
||||
type: 'number',
|
||||
default: 4,
|
||||
min: 1,
|
||||
max: 32,
|
||||
step: 1
|
||||
}
|
||||
],
|
||||
func: function(text, options) {
|
||||
options = options || {};
|
||||
const mode = options.mode === 'strip' ? 'strip' : 'pad';
|
||||
const width = Math.max(1, Math.min(32, parseInt(options.width, 10) || 4));
|
||||
|
||||
return text.replace(/\d+/g, function(num) {
|
||||
if (mode === 'strip') {
|
||||
const stripped = num.replace(/^0+(?=\d)/, '');
|
||||
return stripped === '' ? '0' : stripped;
|
||||
}
|
||||
return num.length >= width ? num : num.padStart(width, '0');
|
||||
});
|
||||
},
|
||||
reverse: function(text, options) {
|
||||
options = options || {};
|
||||
const mode = options.mode === 'strip' ? 'strip' : 'pad';
|
||||
if (mode === 'pad') {
|
||||
return text.replace(/\d+/g, function(num) {
|
||||
const stripped = num.replace(/^0+(?=\d)/, '');
|
||||
return stripped === '' ? '0' : stripped;
|
||||
});
|
||||
}
|
||||
const width = Math.max(1, Math.min(32, parseInt(options.width, 10) || 4));
|
||||
return text.replace(/\d+/g, function(num) {
|
||||
return num.length >= width ? num : num.padStart(width, '0');
|
||||
});
|
||||
},
|
||||
preview: function(text, options) {
|
||||
if (!text) {
|
||||
return '[leading-zeros]';
|
||||
}
|
||||
return this.func(text.slice(0, 40), options);
|
||||
},
|
||||
detector: function(text) {
|
||||
return /\b0+\d+\b/.test(text) || /\b\d{1,3}\b/.test(text);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
// Remove duplicate lines from a list
|
||||
import BaseTransformer from '../BaseTransformer.js';
|
||||
|
||||
export default new BaseTransformer({
|
||||
name: 'List Deduplicate',
|
||||
priority: 50,
|
||||
category: 'format',
|
||||
canDecode: false,
|
||||
description: 'Remove duplicate lines while preserving first occurrence order.',
|
||||
configurableOptions: [
|
||||
{
|
||||
id: 'ignoreCase',
|
||||
label: 'Ignore case when comparing',
|
||||
type: 'boolean',
|
||||
default: false
|
||||
},
|
||||
{
|
||||
id: 'trimLines',
|
||||
label: 'Trim whitespace on each line',
|
||||
type: 'boolean',
|
||||
default: true
|
||||
}
|
||||
],
|
||||
func: function(text, options) {
|
||||
options = options || {};
|
||||
const ignoreCase = !!options.ignoreCase;
|
||||
const trimLines = options.trimLines !== false;
|
||||
const lines = text.split(/\r?\n/);
|
||||
const seen = {};
|
||||
const out = [];
|
||||
|
||||
lines.forEach(function(line) {
|
||||
let value = trimLines ? line.trim() : line;
|
||||
const key = ignoreCase ? value.toLowerCase() : value;
|
||||
if (seen[key]) {
|
||||
return;
|
||||
}
|
||||
seen[key] = true;
|
||||
out.push(value);
|
||||
});
|
||||
|
||||
return out.join('\n');
|
||||
},
|
||||
preview: function(text, options) {
|
||||
if (!text) {
|
||||
return '[dedupe]';
|
||||
}
|
||||
return this.func(text, options).split('\n').slice(0, 3).join('\n') + '...';
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
// Shuffle letters within each word (seeded for reversible decode)
|
||||
import BaseTransformer from '../BaseTransformer.js';
|
||||
|
||||
export default (function() {
|
||||
function seededRandom(seed) {
|
||||
let s = seed >>> 0;
|
||||
return function() {
|
||||
s = (s * 1664525 + 1013904223) >>> 0;
|
||||
return s / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
function shuffleWord(word, seed) {
|
||||
const chars = [...word];
|
||||
const rand = seededRandom(seed);
|
||||
for (let i = chars.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(rand() * (i + 1));
|
||||
[chars[i], chars[j]] = [chars[j], chars[i]];
|
||||
}
|
||||
return chars.join('');
|
||||
}
|
||||
|
||||
function unshuffleWord(word, seed) {
|
||||
const n = word.length;
|
||||
const rand = seededRandom(seed);
|
||||
const swaps = [];
|
||||
for (let i = n - 1; i > 0; i--) {
|
||||
swaps.push([i, Math.floor(rand() * (i + 1))]);
|
||||
}
|
||||
const chars = [...word];
|
||||
for (let s = swaps.length - 1; s >= 0; s--) {
|
||||
const [i, j] = swaps[s];
|
||||
[chars[i], chars[j]] = [chars[j], chars[i]];
|
||||
}
|
||||
return chars.join('');
|
||||
}
|
||||
|
||||
return new BaseTransformer({
|
||||
name: 'Shuffled Letters',
|
||||
priority: 58,
|
||||
category: 'format',
|
||||
configurableOptions: [
|
||||
{
|
||||
id: 'seed',
|
||||
label: 'Shuffle seed (same seed to decode)',
|
||||
type: 'number',
|
||||
default: 42,
|
||||
min: 0,
|
||||
max: 999999
|
||||
}
|
||||
],
|
||||
func: function(text, options) {
|
||||
options = options || {};
|
||||
const seed = Number(options.seed);
|
||||
const base = Number.isFinite(seed) ? seed : 42;
|
||||
let wi = 0;
|
||||
return text.replace(/\S+/g, w => shuffleWord(w, base + wi++));
|
||||
},
|
||||
reverse: function(text, options) {
|
||||
options = options || {};
|
||||
const seed = Number(options.seed);
|
||||
const base = Number.isFinite(seed) ? seed : 42;
|
||||
let wi = 0;
|
||||
return text.replace(/\S+/g, w => unshuffleWord(w, base + wi++));
|
||||
},
|
||||
preview: function(text, options) {
|
||||
if (!text) return '[shuffle]';
|
||||
return this.func(text.slice(0, 20), options);
|
||||
},
|
||||
detector: function(text) {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,67 @@
|
||||
// Typoglycemia — scramble middle letters while keeping first/last
|
||||
import BaseTransformer from '../BaseTransformer.js';
|
||||
|
||||
export default (function() {
|
||||
function hashString(str) {
|
||||
let hash = 2166136261;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash ^= str.charCodeAt(i);
|
||||
hash = Math.imul(hash, 16777619);
|
||||
}
|
||||
return hash >>> 0;
|
||||
}
|
||||
|
||||
function seededShuffle(arr, seed) {
|
||||
const copy = arr.slice();
|
||||
for (let i = copy.length - 1; i > 0; i--) {
|
||||
seed = (Math.imul(seed, 1103515245) + 12345) >>> 0;
|
||||
const j = seed % (i + 1);
|
||||
const tmp = copy[i];
|
||||
copy[i] = copy[j];
|
||||
copy[j] = tmp;
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
|
||||
function scrambleWord(word, minLength) {
|
||||
if (word.length < minLength) {
|
||||
return word;
|
||||
}
|
||||
const first = word.charAt(0);
|
||||
const last = word.charAt(word.length - 1);
|
||||
const middle = word.slice(1, -1).split('');
|
||||
return first + seededShuffle(middle, hashString(word)).join('') + last;
|
||||
}
|
||||
|
||||
return new BaseTransformer({
|
||||
name: 'Typoglycemia',
|
||||
priority: 50,
|
||||
category: 'format',
|
||||
canDecode: false,
|
||||
description: 'Reorder inner letters of each word — readable but scrambled (deterministic shuffle).',
|
||||
configurableOptions: [
|
||||
{
|
||||
id: 'minLength',
|
||||
label: 'Minimum word length to scramble',
|
||||
type: 'number',
|
||||
default: 4,
|
||||
min: 3,
|
||||
max: 12,
|
||||
step: 1
|
||||
}
|
||||
],
|
||||
func: function(text, options) {
|
||||
options = options || {};
|
||||
const minLength = Math.max(3, Math.min(12, parseInt(options.minLength, 10) || 4));
|
||||
return text.replace(/[a-zA-Z]+/g, function(word) {
|
||||
return scrambleWord(word, minLength);
|
||||
});
|
||||
},
|
||||
preview: function(text, options) {
|
||||
if (!text) {
|
||||
return '[typoglycemia]';
|
||||
}
|
||||
return this.func(text.slice(0, 40), options);
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,72 @@
|
||||
// Insert a letter into each word at a fixed position
|
||||
import BaseTransformer from '../BaseTransformer.js';
|
||||
|
||||
export default (function() {
|
||||
function mutateWord(word, insertChar, position) {
|
||||
if (!word || !insertChar) return word;
|
||||
const ch = insertChar.charAt(0);
|
||||
if (position === 'start' || position === 'prefix') return ch + word;
|
||||
if (position === 'end' || position === 'suffix') return word + ch;
|
||||
const idx = parseInt(position, 10);
|
||||
if (Number.isFinite(idx) && idx >= 0 && idx <= word.length) {
|
||||
return word.slice(0, idx) + ch + word.slice(idx);
|
||||
}
|
||||
return word + ch;
|
||||
}
|
||||
|
||||
return new BaseTransformer({
|
||||
name: 'Word Letter Add',
|
||||
priority: 60,
|
||||
category: 'format',
|
||||
configurableOptions: [
|
||||
{
|
||||
id: 'insertChar',
|
||||
label: 'Letter to insert',
|
||||
type: 'text',
|
||||
default: 'x'
|
||||
},
|
||||
{
|
||||
id: 'position',
|
||||
label: 'Position in each word',
|
||||
type: 'select',
|
||||
default: 'end',
|
||||
options: [
|
||||
{ value: 'start', label: 'Start (prefix)' },
|
||||
{ value: 'end', label: 'End (suffix)' },
|
||||
{ value: '1', label: 'After 1st character' },
|
||||
{ value: '2', label: 'After 2nd character' }
|
||||
]
|
||||
}
|
||||
],
|
||||
func: function(text, options) {
|
||||
options = options || {};
|
||||
const insertChar = (options.insertChar || 'x').charAt(0);
|
||||
const position = options.position || 'end';
|
||||
return text.replace(/\S+/g, w => mutateWord(w, insertChar, position));
|
||||
},
|
||||
reverse: function(text, options) {
|
||||
options = options || {};
|
||||
const insertChar = (options.insertChar || 'x').charAt(0);
|
||||
const position = options.position || 'end';
|
||||
const esc = insertChar.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
if (position === 'start' || position === 'prefix') {
|
||||
return text.replace(new RegExp('^' + esc + '(\\S+)|(\\s|^)' + esc + '(\\S+)', 'g'), '$1$2$3');
|
||||
}
|
||||
if (position === 'end' || position === 'suffix') {
|
||||
return text.replace(new RegExp('(\\S+)' + esc + '(?=\\s|$)', 'g'), '$1');
|
||||
}
|
||||
const idx = parseInt(position, 10);
|
||||
if (Number.isFinite(idx)) {
|
||||
return text.replace(new RegExp('(\\S{' + idx + '})' + esc + '(\\S*)', 'g'), '$1$2');
|
||||
}
|
||||
return text.replace(new RegExp(esc + '$'), '');
|
||||
},
|
||||
preview: function(text, options) {
|
||||
if (!text) return '[+letter]';
|
||||
return this.func(text.slice(0, 24), options);
|
||||
},
|
||||
detector: function(text) {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,59 @@
|
||||
// Replace a letter at a fixed index in each word
|
||||
import BaseTransformer from '../BaseTransformer.js';
|
||||
|
||||
export default (function() {
|
||||
function changeAt(word, index, replacement) {
|
||||
if (!word || !replacement) return word;
|
||||
let i = index;
|
||||
if (i < 0) i = word.length + i;
|
||||
if (i < 0 || i >= word.length) return word;
|
||||
return word.slice(0, i) + replacement.charAt(0) + word.slice(i + 1);
|
||||
}
|
||||
|
||||
return new BaseTransformer({
|
||||
name: 'Word Letter Change',
|
||||
priority: 60,
|
||||
category: 'format',
|
||||
configurableOptions: [
|
||||
{
|
||||
id: 'index',
|
||||
label: 'Character index to replace',
|
||||
type: 'number',
|
||||
default: 0,
|
||||
min: -20,
|
||||
max: 20
|
||||
},
|
||||
{
|
||||
id: 'replaceChar',
|
||||
label: 'Replacement letter',
|
||||
type: 'text',
|
||||
default: 'x'
|
||||
}
|
||||
],
|
||||
func: function(text, options) {
|
||||
options = options || {};
|
||||
const index = Number.isFinite(Number(options.index)) ? Number(options.index) : 0;
|
||||
const rep = (options.replaceChar || 'x').charAt(0);
|
||||
return text.replace(/\S+/g, w => changeAt(w, index, rep));
|
||||
},
|
||||
reverse: function(text, options) {
|
||||
options = options || {};
|
||||
const index = Number.isFinite(Number(options.index)) ? Number(options.index) : 0;
|
||||
const rep = (options.replaceChar || 'x').charAt(0);
|
||||
const esc = rep.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
return text.replace(/\S+/g, w => {
|
||||
let i = index;
|
||||
if (i < 0) i = w.length + i;
|
||||
if (i < 0 || i >= w.length) return w;
|
||||
const re = new RegExp('^(.{' + i + '})' + esc + '(.*)$');
|
||||
const m = w.match(re);
|
||||
return m ? m[1] + '?' + m[2] : w;
|
||||
});
|
||||
},
|
||||
preview: function(text, options) {
|
||||
if (!text) return '[~letter]';
|
||||
return this.func(text.slice(0, 24), options);
|
||||
},
|
||||
canDecode: false
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,49 @@
|
||||
// Remove a letter at a fixed index from each word
|
||||
import BaseTransformer from '../BaseTransformer.js';
|
||||
|
||||
export default (function() {
|
||||
function removeAt(word, index) {
|
||||
if (!word) return word;
|
||||
let i = index;
|
||||
if (i < 0) i = word.length + i;
|
||||
if (i < 0 || i >= word.length) return word;
|
||||
return word.slice(0, i) + word.slice(i + 1);
|
||||
}
|
||||
|
||||
return new BaseTransformer({
|
||||
name: 'Word Letter Remove',
|
||||
priority: 60,
|
||||
category: 'format',
|
||||
configurableOptions: [
|
||||
{
|
||||
id: 'index',
|
||||
label: 'Character index to remove (0 = first, -1 = last)',
|
||||
type: 'number',
|
||||
default: -1,
|
||||
min: -20,
|
||||
max: 20
|
||||
}
|
||||
],
|
||||
func: function(text, options) {
|
||||
options = options || {};
|
||||
const index = Number.isFinite(Number(options.index)) ? Number(options.index) : -1;
|
||||
return text.replace(/\S+/g, w => removeAt(w, index));
|
||||
},
|
||||
reverse: function(text, options) {
|
||||
options = options || {};
|
||||
const index = Number.isFinite(Number(options.index)) ? Number(options.index) : -1;
|
||||
const placeholder = '\u0001';
|
||||
return text.replace(/\S+/g, w => {
|
||||
if (index === 0 || index <= -w.length) return placeholder + w;
|
||||
if (index === -1 || index === w.length - 1) return w + placeholder;
|
||||
const i = index < 0 ? w.length + index : index;
|
||||
return w.slice(0, i) + placeholder + w.slice(i);
|
||||
}).replace(/\u0001/g, '?');
|
||||
},
|
||||
preview: function(text, options) {
|
||||
if (!text) return '[-letter]';
|
||||
return this.func(text.slice(0, 24), options);
|
||||
},
|
||||
canDecode: false
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,99 @@
|
||||
// ASL SignWriting transform
|
||||
import BaseTransformer from '../BaseTransformer.js';
|
||||
|
||||
export default new BaseTransformer({
|
||||
name: 'ASL SignWriting',
|
||||
priority: 0,
|
||||
canDecode: false,
|
||||
description: 'American Sign Language fingerspelling in SignWriting (ISWA 2010). Horizontal or vertical layout.',
|
||||
configurableOptions: [
|
||||
{
|
||||
id: 'layout',
|
||||
label: 'Layout',
|
||||
type: 'select',
|
||||
default: 'horizontal',
|
||||
options: [
|
||||
{ label: 'Horizontal', value: 'horizontal' },
|
||||
{ label: 'Vertical', value: 'vertical' }
|
||||
]
|
||||
}
|
||||
],
|
||||
|
||||
THIN: '\u2004',
|
||||
NBSP: '\u00A0',
|
||||
|
||||
aslMap: {
|
||||
'A': '𝣷𝪜', 'B': '𝡇𝪜', 'C': '𝡭𝪜', 'D': '𝠁𝪜', 'E': '𝡊𝪜',
|
||||
'F': '𝣎𝪜', 'G': '𝣰', 'H': '𝠕𝪢', 'I': '𝢒𝪜',
|
||||
'J': '𝦢𝪬\n𝢒𝪜', 'K': '𝡀𝪜', 'L': '𝣜𝪜', 'M': '𝢍𝪜',
|
||||
'N': '𝠙𝪜', 'O': '𝡶𝪜', 'P': '𝡀𝪜𝪡', 'Q': '𝣱𝪜𝪡',
|
||||
'R': '𝠚𝪜', 'S': '𝤃𝪜', 'T': '𝣻𝪜', 'U': '𝠕𝪜',
|
||||
'V': '𝠎𝪜', 'W': '𝢇𝪜', 'X': '𝠆𝪜', 'Y': '𝢚𝪜',
|
||||
'Z': '\u2004𝥅𝪪\n𝠀𝪜',
|
||||
'.': '𝪈𝪢', ',': '𝪇𝪢', ':': '𝪊𝪢', ';': '𝪉𝪢',
|
||||
'(': '𝪋𝪢', ')': '𝪋𝪦', '?': '𝦟𝪝𝪬\n𝠀𝪜',
|
||||
'0': '𝡶𝪜', '1': '𝠀𝪜', '2': '𝠎𝪜', '3': '𝠞𝪜',
|
||||
'4': '𝡄𝪜', '5': '𝡌𝪜', '6': '𝢇𝪜', '7': '𝢥𝪜',
|
||||
'8': '𝢻𝪜', '9': '𝣎𝪜'
|
||||
},
|
||||
|
||||
func: function(text, options) {
|
||||
var layout = (options && options.layout) || 'horizontal';
|
||||
text = text.toUpperCase();
|
||||
|
||||
if (layout === 'vertical') {
|
||||
var words = text.split(/\s+/);
|
||||
var wordBlocks = [];
|
||||
for (var w = 0; w < words.length; w++) {
|
||||
var chars = [];
|
||||
for (var c = 0; c < words[w].length; c++) {
|
||||
var val = this.aslMap[words[w][c]];
|
||||
if (val !== undefined) chars.push(val);
|
||||
}
|
||||
wordBlocks.push(chars.join('\n\n'));
|
||||
}
|
||||
return wordBlocks.join('\n\n\n');
|
||||
}
|
||||
|
||||
// Horizontal layout
|
||||
var SPACE_TOKEN = this.NBSP + this.NBSP;
|
||||
var signs = [];
|
||||
for (var i = 0; i < text.length; i++) {
|
||||
var ch = text[i];
|
||||
if (ch === ' ') {
|
||||
signs.push([SPACE_TOKEN]);
|
||||
} else {
|
||||
var val = this.aslMap[ch];
|
||||
if (val !== undefined) {
|
||||
signs.push(val.split('\n'));
|
||||
} else {
|
||||
signs.push([ch]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var maxH = 1;
|
||||
for (var s = 0; s < signs.length; s++) {
|
||||
if (signs[s].length > maxH) maxH = signs[s].length;
|
||||
}
|
||||
|
||||
var lanes = [];
|
||||
for (var r = 0; r < maxH; r++) lanes.push('');
|
||||
|
||||
for (var s = 0; s < signs.length; s++) {
|
||||
var padCount = maxH - signs[s].length;
|
||||
var padded = [];
|
||||
for (var p = 0; p < padCount; p++) padded.push(this.NBSP);
|
||||
for (var p = 0; p < signs[s].length; p++) padded.push(signs[s][p]);
|
||||
for (var r = 0; r < maxH; r++) {
|
||||
lanes[r] += padded[r];
|
||||
}
|
||||
}
|
||||
return lanes.join('\n');
|
||||
},
|
||||
|
||||
preview: function(text) {
|
||||
if (!text) return '[ASL SignWriting]';
|
||||
return this.func(text.slice(0, 5));
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
// IPA Lip-Reading SignWriting transform
|
||||
import BaseTransformer from '../BaseTransformer.js';
|
||||
|
||||
export default new BaseTransformer({
|
||||
name: 'IPA Lip-Reading',
|
||||
priority: 0,
|
||||
canDecode: false,
|
||||
description: 'Converts IPA phonetic text to SignWriting lip-reading mouth shapes (ISWA 2010 head/face symbols).',
|
||||
|
||||
HEAD: '𝧿', EYE: '𝨔',
|
||||
|
||||
ipaToSw: {
|
||||
'p': '𝩓𝨵', 'b': '𝩓𝨮', 'm': '𝩓𝨳',
|
||||
'ɓ': '𝩓𝨮', 'ʙ': '𝩓𝨮', 'ɸ': '𝩓𝨵𝪛', 'β': '𝩓𝨮',
|
||||
'f': '𝩥𝨵', 'v': '𝩥𝨮', 'ʋ': '𝩥𝨮', 'ɱ': '𝩥𝨳',
|
||||
'θ': '𝩛𝨵', 'ð': '𝩛𝨮',
|
||||
't': '𝩜𝨵', 'd': '𝩜𝨮', 'n': '𝩜𝨳',
|
||||
's': '𝩀𝨵𝪛', 'z': '𝩀𝨮', 'l': '𝩜𝪤', 'r': '𝩟', 'ɾ': '𝩟', 'ɹ': '𝩟', 'ɻ': '𝩟',
|
||||
'ʃ': '𝩍𝨵𝪛', 'ʒ': '𝩍𝨮',
|
||||
'c': '𝩀𝨵', 'ɟ': '𝩀𝨮', 'ɲ': '𝩀𝨳', 'ç': '𝩀𝨵𝪛', 'ʝ': '𝩀𝨮', 'j': '𝩀', 'ʎ': '𝩀',
|
||||
'k': '𝩄𝨵', 'g': '𝩄𝨮', 'ŋ': '𝩄𝨳', 'x': '𝩄𝨵𝪛', 'ɣ': '𝩄𝨮', 'w': '𝩆', 'ʍ': '𝩆',
|
||||
'q': '𝩉𝨵', 'ɢ': '𝩉𝨮', 'ɴ': '𝩉𝨳', 'χ': '𝩉𝨵𝪛', 'ʁ': '𝩉𝨮', 'ʀ': '𝩉𝨮',
|
||||
'ʕ': '𝩌𝨮', 'ħ': '𝩌𝨮', 'ʔ': '𝩡', 'h': '𝩄𝨵𝪛', 'ɦ': '𝩄𝨵𝪛',
|
||||
'i': '𝩀', 'y': '𝩆', 'ɨ': '𝩀', 'ʉ': '𝩆', 'ɯ': '𝩐', 'u': '𝩆',
|
||||
'ɪ': '𝩊', 'ʏ': '𝩇', 'ʊ': '𝩇',
|
||||
'e': '𝩊', 'ø': '𝩇', 'ɘ': '𝩊', 'ɵ': '𝩇', 'ɤ': '𝩊', 'o': '𝩇', 'ə': '𝩊', 'ɚ': '𝩊',
|
||||
'ɛ': '𝩈', 'œ': '𝩈', 'ɜ': '𝩈', 'ɞ': '𝩈', 'ʌ': '𝩉', 'ɔ': '𝩉',
|
||||
'a': '𝩌', 'ɶ': '𝩌', 'ä': '𝩌', 'ɑ': '𝩌', 'ɒ': '𝩌', 'æ': '𝩌', 'ɐ': '𝩌',
|
||||
'ʘ': '𝩓𝨶', 'ǀ': '𝩣𝨶', 'ǃ': '𝩡𝨶', 'ǂ': '𝩡𝨶', 'ǁ': '𝩡𝨶'
|
||||
},
|
||||
|
||||
diphthongs: {
|
||||
'aɪ': ['𝩌', '𝩀'], 'aʊ': ['𝩌', '𝩆'], 'eɪ': ['𝩊', '𝩀'],
|
||||
'oʊ': ['𝩇', '𝩆'], 'ɔɪ': ['𝩉', '𝩀'], 'əʊ': ['𝩊', '𝩆'],
|
||||
'ɪə': ['𝩊', '𝩊'], 'eə': ['𝩊', '𝩌'], 'ʊə': ['𝩇', '𝩊']
|
||||
},
|
||||
|
||||
skipChars: { 'ˈ': 1, 'ˌ': 1, 'ː': 1, ' ': 1, '\t': 1, '\n': 1 },
|
||||
|
||||
func: function(text) {
|
||||
var result = [], i = 0, HEAD = this.HEAD, EYE = this.EYE;
|
||||
while (i < text.length) {
|
||||
if (this.skipChars[text[i]]) { i++; continue; }
|
||||
// Check diphthongs (2-char)
|
||||
if (i + 1 < text.length) {
|
||||
var di = text[i] + text[i + 1];
|
||||
if (this.diphthongs[di]) {
|
||||
var shapes = this.diphthongs[di];
|
||||
for (var s = 0; s < shapes.length; s++) {
|
||||
result.push(HEAD + EYE + shapes[s]);
|
||||
if (s < shapes.length - 1) result.push('\u2192');
|
||||
}
|
||||
i += 2; continue;
|
||||
}
|
||||
}
|
||||
var ch = text[i]; i++;
|
||||
if (this.ipaToSw[ch]) {
|
||||
result.push(HEAD + EYE + this.ipaToSw[ch]);
|
||||
} else {
|
||||
result.push(HEAD + EYE + '𝨻');
|
||||
}
|
||||
}
|
||||
return result.join(' ');
|
||||
},
|
||||
|
||||
preview: function(text) {
|
||||
if (!text) return '[IPA Lip-Reading]';
|
||||
return this.func(text.slice(0, 8));
|
||||
}
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user