Use it however, but the notes feature is fragile
// ==UserScript==
// @name 310X-Word Replacer WTRLAB
// @match https://wtr-lab.com/en/*
// @version 31.1
// @namespace WordReplace310XXXX
// @description Use it however, but the notes feature is fragile
// @grant none
// ==/UserScript==
(function () {
'use strict';
const STORAGE_KEY = 'wordReplacerPairsV3';
// Load data or initialize empty object
let data = loadData();
// Main UI button
const mainButton = document.createElement('button');
mainButton.textContent = 'Word Replacer';
Object.assign(mainButton.style, {
position: 'fixed',
bottom: '20px', // always 20px from bottom
right: '20px',
zIndex: '100001', // above everything except popup panel
padding: '8px 14px',
fontSize: '16px',
backgroundColor: '#333',
color: '#fff',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
});
document.body.appendChild(mainButton);
let popup = null;
mainButton.addEventListener('click', () => {
if (popup) {
closePopup();
} else {
openPopup();
replaceTextInChapter();
}
});
function loadData() {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return {};
try {
return JSON.parse(raw);
} catch {
return {};
}
}
function saveData(obj) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(obj));
}
function closePopup() {
if (popup) {
popup.remove();
popup = null;
}
}
// Escape regex helper
const escapeRegex = (str) => str.replace(/[.*+?^${}()|[\]\\",]/g, '\\$&');
// Check if index is start of sentence
function isStartOfSentence(index, fullText) {
if (index === 0) return true; // start of paragraph/text
const before = fullText.slice(0, index);
// If the slice is only whitespace, treat as start
if (/^\s*$/.test(before)) return true;
// Trim trailing spaces for checks of punctuation/endings
const trimmed = before.replace(/\s+$/, '');
// 1) sentence-ending punctuation (., !, ?, …) possibly followed by closing quotes/brackets:
if (/[.!?…]["”’')\]]*$/.test(trimmed)) return true;
// 2) newline right before (dialogue line breaks etc.)
if (/[\n\r]\s*$/.test(before)) return true;
// 3) opening quote/paren immediately before the match OR opening quote then space
if (/["“”'‘(\[]\s*$/.test(before)) return true;
// 4) after any **double quotation mark** (straight or curly) **without space**
if (/["“”]$/.test(before)) return true;
// 5) after "Chapter XX:" optionally followed by a comma and space
if (/Chapter\s+\d+:\s*,?\s*$/.test(before)) return true;
return false;
}
function isInsideDialogueAtIndex(htmlText, index) {
const leftQuotes = `"“‘`; // opening quotes
const rightQuotes = `"”’`; // closing quotes
let quoteStack = [];
// Search left from the start to the index
for (let i = 0; i < index; i++) {
// Stop counting at paragraph or line break
if (htmlText.slice(i).match(/^<\s*(?:p|br)[^>]*>/i)) break;
const char = htmlText[i];
if (leftQuotes.includes(char)) {
quoteStack.push(char);
} else if (rightQuotes.includes(char)) {
// Pop matching opening quote if exists
if (quoteStack.length > 0) quoteStack.pop();
}
}
return quoteStack.length > 0; // true = inside dialogue
}
// Preserve first capital helper
function applyPreserveCapital(orig, replacement) {
if (!orig) return replacement;
if (orig[0] >= 'A' && orig[0] <= 'Z') {
return replacement.charAt(0).toUpperCase() + replacement.slice(1);
}
return replacement;
}
function buildIgnoreRegex(from, ignoreTerm, entry, wildcardSymbol) {
const flags = entry.ignoreCapital ? 'gi' : 'g';
let basePattern = escapeRegex(from).replace(new RegExp(`\\${wildcardSymbol}`, 'g'), '.');
if (entry.noTrailingSpace) {
basePattern = basePattern.trim();
}
if (ignoreTerm) {
// Negative lookahead for the ignore phrase (allowing spaces or punctuation between)
return new RegExp(
basePattern + `(?![\\s"“”'’,.-]+${escapeRegex(ignoreTerm)})`,
flags
);
} else {
return new RegExp(basePattern, flags);
}
}
// The core replacement function, applies all enabled replacements with flags
function applyReplacements(text, replacements) {
let replacedText = text;
const WILDCARD = '@';
// Expanded boundary characters
const punctuationRegex = /^[\W_'"“”‘’„,;:!?~()\[\]{}<>【】「」『』()《》〈〉—–-]|[\W_'"“”‘’„,;:!?~()\[\]{}<>【】「」『』()《》〈〉—–-]$/;
const quoteChars = `"'“”‘’`;
for (const entry of replacements) {
if (!entry.from || !entry.to) continue;
const flags = entry.ignoreCapital ? 'gi' : 'g';
let searchTerm = entry.from;
if (entry.noTrailingSpace) searchTerm = searchTerm.trimEnd();
// Handle optional ignore term |term| (before or after)
let ignoreTerm = null;
const prefixMatch = searchTerm.match(/^\|(.*?)\|\s*(.+)$/);
const suffixMatch = searchTerm.match(/^(.*?)\s*\|(.*?)\|$/);
if (prefixMatch) {
ignoreTerm = { type: 'before', value: prefixMatch[1] };
searchTerm = prefixMatch[2];
} else if (suffixMatch) {
ignoreTerm = { type: 'after', value: suffixMatch[2] };
searchTerm = suffixMatch[1];
}
// PATCH: allow any quote char at start if searchTerm starts with a quote
if (quoteChars.includes(searchTerm.charAt(0))) {
searchTerm = `[${quoteChars}]` + escapeRegex(searchTerm.slice(1));
} else {
searchTerm = escapeRegex(searchTerm);
}
// Placeholder ^ handling
const caretCountFrom = (entry.from.match(/\^/g) || []).length;
const caretCountTo = (entry.to.match(/\^/g) || []).length;
const usePlaceholder = caretCountFrom === 1 && caretCountTo === 1;
let base = usePlaceholder ? searchTerm.replace('\\^', '([^\\s])') : searchTerm.replace(new RegExp(`\\${WILDCARD}`, 'g'), '.');
// Determine boundaries
const firstChar = entry.from.charAt(0);
const lastChar = entry.from.charAt(entry.from.length - 1);
const skipBoundaries = punctuationRegex.test(firstChar) || punctuationRegex.test(lastChar);
let patternStr = (entry.allInstances || skipBoundaries)
? base
: `(?<=^|[^A-Za-z0-9])${base}(?=[^A-Za-z0-9]|$)`;
// Apply ignore-term properly
if (ignoreTerm && ignoreTerm.value) {
const escapedIgnore = escapeRegex(ignoreTerm.value);
if (ignoreTerm.type === 'before') {
patternStr = `(?<!${escapedIgnore})${patternStr}`;
} else if (ignoreTerm.type === 'after') {
patternStr = `${patternStr}(?!${escapedIgnore}\\s*)`; // allow trailing spaces
} else {
patternStr = `(?<!${escapedIgnore})${patternStr}(?!${escapedIgnore}\\s*)`;
}
}
const regex = new RegExp(patternStr, flags);
let newText = '';
let lastIndex = 0;
let match;
while ((match = regex.exec(replacedText)) !== null) {
const idx = match.index;
const insideDialogue = isInsideDialogueAtIndex(replacedText, idx);
if ((entry.insideDialogueOnly && !insideDialogue) || (entry.outsideDialogueOnly && insideDialogue)) continue;
newText += replacedText.slice(lastIndex, idx);
let replacementBase = entry.noTrailingSpace ? entry.to.trimEnd() : entry.to;
if (usePlaceholder && match[1] !== undefined) replacementBase = replacementBase.replace('^', match[1]);
// START OF SENTENCE capitalization check
const startSentence = entry.startOfSentence && isStartOfSentence(idx, replacedText);
const finalReplacement = startSentence
? (entry.preserveFirstCapital
? applyPreserveCapital(match[0], replacementBase)
: replacementBase.charAt(0).toUpperCase() + replacementBase.slice(1))
: (entry.preserveFirstCapital
? applyPreserveCapital(match[0], replacementBase)
: replacementBase);
newText += finalReplacement;
lastIndex = idx + match[0].length;
}
if (lastIndex < replacedText.length) newText += replacedText.slice(lastIndex);
replacedText = newText;
}
return replacedText;
}
// The main replaceTextInChapter, preserves old proportional slicing and paragraph-limited replacement
function replaceTextInChapter() {
const seriesId = (() => {
const urlMatch = location.href.match(/\/novel\/(\d+)\//i);
if (urlMatch) return urlMatch[1];
const crumb = document.querySelector('.breadcrumb li.breadcrumb-item a[href*="/novel/"]');
if (crumb) {
const crumbMatch = crumb.href.match(/\/novel\/(\d+)\//i);
if (crumbMatch) return crumbMatch[1];
}
return null;
})();
let replacements = [];
for (const key in data) {
if (key === 'global' || (seriesId && key === `series-${seriesId}`)) {
replacements = replacements.concat(data[key].filter(e => e.enabled));
}
}
if (replacements.length === 0) return false;
const paragraphs = document.querySelectorAll(
'div.chapter-body p[data-line], h3.chapter-title, div.post-content *'
);
let replacedAny = false;
paragraphs.forEach(p => {
const textNodes = [];
const originalLengths = [];
let originalText = '';
const walker = document.createTreeWalker(p, NodeFilter.SHOW_TEXT, null, false);
while (walker.nextNode()) {
const node = walker.currentNode;
if (!node.nodeValue) continue;
textNodes.push(node);
originalLengths.push(node.nodeValue.length);
originalText += node.nodeValue;
}
if (!originalText) return;
// --- Step 1: Apply all replacements on flat text ---
let replacedText = applyReplacements(originalText, replacements);
// --- Step 2: Proportional slicing back into text nodes ---
const totalOriginalLength = originalText.length;
const totalReplacedLength = replacedText.length;
let currentIndex = 0;
textNodes.forEach((node, i) => {
const proportion = originalLengths[i] / totalOriginalLength;
let sliceLength = Math.round(proportion * totalReplacedLength);
if (i === textNodes.length - 1) sliceLength = totalReplacedLength - currentIndex;
node.nodeValue = replacedText.slice(currentIndex, currentIndex + sliceLength);
currentIndex += sliceLength;
});
// --- Step 3: Wrap note entries in <span> ---
textNodes.forEach(node => {
let nodeVal = node.nodeValue;
replacements.forEach(entry => {
if (entry.note && entry.note.trim()) {
let idx = 0;
while ((idx = nodeVal.indexOf(entry.to, idx)) !== -1) {
const parent = node.parentNode;
if (!parent) break;
const span = document.createElement('span');
span.className = 'text-patch system_term';
span.dataset.note = entry.note;
span.textContent = entry.to;
const before = nodeVal.slice(0, idx);
const after = nodeVal.slice(idx + entry.to.length);
if (before) parent.insertBefore(document.createTextNode(before), node);
parent.insertBefore(span, node);
nodeVal = after;
idx = 0; // restart in remaining text
node.nodeValue = nodeVal;
if (!nodeVal) {
parent.removeChild(node);
break;
}
}
}
});
});
replacedAny = true;
});
if (replacedAny) console.log('Replacements done on chapter paragraphs.');
return replacedAny;
}
function wrapNotesInParagraph(paragraph, replacements) {
const html = paragraph.innerHTML;
let newHTML = html;
replacements.forEach(rep => {
if (rep.note && rep.note.trim()) {
// Regex escape
const fromEscaped = rep.from.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
// Wrap the entire matching text in a span with data-note
const regex = new RegExp(`(${fromEscaped})`, 'gi');
newHTML = newHTML.replace(regex, `<span class="text-patch system_term" data-note="${rep.note}">$1</span>`);
}
});
paragraph.innerHTML = newHTML;
}
function runReplacementMultiple(times = 1, delay = 100) {
let count = 0;
// ---------------------
// Single global note modal
// ---------------------
function initInlinePopoverButtons() {
const seriesId = window.seriesId || 'default-series';
const shownotesRaw = localStorage.getItem('shownotes') || '';
const shownotesSet = new Set(shownotesRaw.split(',').filter(Boolean));
const notesVisible = shownotesSet.has(seriesId); // only visible if enabled for this series
document.querySelectorAll('span.text-patch.system_term[data-note]').forEach(span => {
// Skip if we already initialized a button for this span
if (span.dataset.hasPencil === 'true') return;
// Mark as having a pencil
span.dataset.hasPencil = 'true';
// Create tiny pencil button
const btn = document.createElement('button');
btn.textContent = '✎';
btn.title = 'Show Note';
Object.assign(btn.style, {
fontSize: '10px',
padding: '0 2px',
marginLeft: '4px',
cursor: 'pointer',
lineHeight: '1',
verticalAlign: 'middle',
border: 'none',
background: 'transparent',
display: notesVisible ? 'inline' : 'none', // invisible by default
});
// Insert after the span
span.insertAdjacentElement('afterend', btn);
// Create popover
const pop = document.createElement('div');
pop.className = 'user-popover';
Object.assign(pop.style, {
position: 'absolute',
zIndex: 9999,
maxWidth: '280px',
background: 'black',
color: 'white',
border: '1px solid #ccc',
borderRadius: '4px',
boxShadow: '0 2px 8px rgba(0,0,0,0.2)',
padding: '8px',
display: 'none',
});
const desc = document.createElement('div');
desc.className = 'patch-desc';
desc.textContent = 'Note: ' + span.dataset.note;
pop.appendChild(desc);
document.body.appendChild(pop);
// Store reference on span
span._popover = pop;
// Button click toggles popover
btn.addEventListener('click', e => {
e.stopPropagation();
document.querySelectorAll('.user-popover').forEach(p => {
if (p !== pop) p.style.display = 'none';
});
desc.textContent = 'Note: ' + span.dataset.note;
const rect = span.getBoundingClientRect();
// Position below the span
pop.style.top = `${window.scrollY + rect.bottom + 5}px`; // 5px gap below
pop.style.left = `${window.scrollX + rect.left}px`;
// Toggle visibility
pop.style.display = pop.style.display === 'block' ? 'none' : 'block';
});
});
// Click outside -> hide all popovers
document.addEventListener('click', () => {
document.querySelectorAll('.user-popover').forEach(p => (p.style.display = 'none'));
});
}
function removeExtraPencils() {
document.querySelectorAll('span.text-patch.system_term[data-note]').forEach(span => {
// find all button siblings immediately after this span
const siblings = [];
let next = span.nextElementSibling;
while (next && next.tagName === 'BUTTON') {
if (next.textContent.trim() === '✎') siblings.push(next);
next = next.nextElementSibling;
}
// keep only the first ✎, remove the rest
siblings.slice(1).forEach(btn => btn.remove());
});
}
function nextPass() {
const replaced = replaceTextInChapter();
initInlinePopoverButtons();
count++;
if (count < times) {
setTimeout(nextPass, delay);
} else {
// ✅ all passes finished, wait a bit then clean up
setTimeout(removeExtraPencils, 150);
}
}
nextPass(); // start first pass immediately
}
setTimeout(() => runReplacementMultiple(1, 100), 2000);
// Reactive URL detection (History API hook)
(function () {
let lastUrl = location.href;
function checkUrlChange() {
const currentUrl = location.href;
if (currentUrl !== lastUrl) {
lastUrl = currentUrl;
runReplacementMultiple(1, 100); // multi-pass on SPA navigation
removeExtraPencils();
}
}
// Wrap pushState
const originalPushState = history.pushState;
history.pushState = function () {
originalPushState.apply(this, arguments);
window.dispatchEvent(new Event("locationchange"));
};
// Wrap replaceState
const originalReplaceState = history.replaceState;
history.replaceState = function () {
originalReplaceState.apply(this, arguments);
window.dispatchEvent(new Event("locationchange"));
};
// Listen to Back/Forward
window.addEventListener("popstate", () => window.dispatchEvent(new Event("locationchange")));
// Unified listener
window.addEventListener("locationchange", checkUrlChange);
// ✅ Forcefully run once immediately on reload
runReplacementMultiple(1, 100);
})();
// ===============
// UI popup code
// ===============
function openPopup() {
if (popup) return; // Prevent multiple
popup = document.createElement('div');
Object.assign(popup.style, {
position: 'fixed',
bottom: '70px', // push above the main button
right: '10px',
width: '100vw', // scale to viewport
maxWidth: '370px', // cap at your original
height: 'auto',
maxHeight: 'none',
backgroundColor: '#fff',
border: '1px solid #aaa',
padding: '15px',
boxShadow: '0 0 15px rgba(0,0,0,0.2)',
overflow: 'visible',
zIndex: '100000', // below the button so button stays clickable
fontFamily: 'Arial, sans-serif',
fontSize: '14px',
});
document.body.appendChild(popup);
// Toggle button for the list
const toggleListBtn = document.createElement('button');
toggleListBtn.textContent = 'Show List';
toggleListBtn.style.marginBottom = '1px';
toggleListBtn.style.display = 'block';
toggleListBtn.style.color = 'black'
styleButton(toggleListBtn);
popup.appendChild(toggleListBtn);
// Info button
const infoBtn = document.createElement('button');
infoBtn.textContent = 'Info'; // Label it properly
infoBtn.style.marginLeft = '6px'; // space from the list button
infoBtn.style.padding = '5px 10px'; // optional: smaller padding if needed
infoBtn.style.alignSelf = 'flex-start';
infoBtn.style.color = 'black'; // <-- shift up to top of flex container
styleButton(infoBtn);
// Container for buttons
const topBtnContainer = document.createElement('div');
topBtnContainer.style.display = 'flex';
topBtnContainer.style.alignItems = 'center';
topBtnContainer.appendChild(toggleListBtn);
topBtnContainer.appendChild(infoBtn);
popup.appendChild(topBtnContainer);
// Info box (hidden by default)
const infoBox = document.createElement('div');
Object.assign(infoBox.style, {
maxHeight: '0',
overflow: 'hidden',
backgroundColor: '#fff',
color: 'black',
border: '1px solid #000',
padding: '0 10px',
marginTop: '10px',
fontSize: '13px',
maxHeightWhenOpen: '200px',
lineHeight: '1.4',
overflowY: 'auto',
transition: 'max-height 0.3s ease, padding 0.3s ease',
});
infoBox.innerHTML = `
<div style="padding:10px 0;">
<strong>Replacement System Info:</strong>
<ul style="margin:5px 0; padding-left:18px;">
<li><strong>Ignore Capital:</strong> Match case-insensitively.</li>
<li><strong>Start of Sentence:</strong> Only capitalize if the word starts a sentence.</li>
<li><strong>Fuzzy Match:</strong> Ignore boundaries, match anywhere.</li>
<li><strong>Preserve Capital:</strong> Keep first letter capitalized if original was capitalized.</li>
<li><strong>No Trailing Space:</strong> Trim trailing space in replacement.</li>
<li><strong>Inside Dialogue Only:</strong> Replace only inside quotation marks.</li>
<li><strong>Outside Dialogue Only:</strong> Replace only outside quotation marks.</li>
<li><strong>Global:</strong> Makes the entry apply to all novels.</li>
<li><strong>|ignore this|:</strong> Use before or after a word to ignore specific matches. Example: <code>|ignore |term</code> or <code>term| ignore|</code>. Spaces must be inside the <code>||</code>.</li>
<li><strong>@ wildcard:</strong> Any character substitution. Example: <code>fr@t</code> replaces fret, frat, frit, etc.</li>
<li><strong>^ special placeholder:</strong> Use <code>^</code> in Find like <code>Th^t</code> and in Replace like <code>Br^</code>. The character at <code>^</code> in Find will be preserved in the replacement.</li>
<li><strong>Edit Entries:</strong> Use 'Show List', tap an entry to make edits and change the series ID. By default, it will be applied only to whatever novel you're on currently. If you entered a term while in Library, it will default to an empty series ID, which is global.</li>
<li>The Show Note requires you have go to the term editor in the Show List, but this is a fragile feature, I don't recommend it.<li>
</ul>
</div>
`;
popup.appendChild(infoBox);
// Info button click
infoBtn.addEventListener('click', (e) => {
if (infoBox.style.maxHeight && infoBox.style.maxHeight !== '0px') {
infoBox.style.maxHeight = '0';
infoBox.style.padding = '0 10px';
} else {
infoBox.style.maxHeight = '200px'; // fixed max height
infoBox.style.padding = '10px';
}
e.stopPropagation();
});
// Close info box if clicking outside
document.addEventListener('click', (e) => {
if (!infoBox.contains(e.target) && e.target !== infoBtn) {
infoBox.style.maxHeight = '0';
infoBox.style.padding = '0 10px';
}
});
// Create invert colors button
const invertBtn = document.createElement('button');
invertBtn.textContent = 'Invert Colors';
invertBtn.style.marginLeft = '6px'; // spacing from previous button
invertBtn.style.padding = '5px 10px';
invertBtn.style.alignSelf = 'flex-start';
invertBtn.style.color = 'black';
styleButton(invertBtn);
topBtnContainer.appendChild(invertBtn);
// Track state, restore from localStorage
let isInverted = localStorage.getItem('replacementUIInverted') === 'true';
// Function to apply inversion to popup and infoBox
function applyInversion(state) {
isInverted = state;
localStorage.setItem('replacementUIInverted', state); // persist across reloads
if (isInverted) {
popup.style.backgroundColor = '#000';
popup.style.color = '#fff';
infoBox.style.backgroundColor = '#000';
infoBox.style.color = '#fff';
Array.from(popup.querySelectorAll('*')).forEach(el => {
if (['INPUT', 'SELECT', 'TEXTAREA'].includes(el.tagName)) {
el.style.backgroundColor = '#222';
el.style.color = '#fff';
}
if (el.tagName === 'BUTTON') {
el.style.backgroundColor = '#333';
el.style.color = '#fff';
}
});
} else {
popup.style.backgroundColor = '#fff';
popup.style.color = '#000';
infoBox.style.backgroundColor = '#fff';
infoBox.style.color = '#000';
Array.from(popup.querySelectorAll('*')).forEach(el => {
if (!['INPUT', 'SELECT', 'TEXTAREA', 'BUTTON'].includes(el.tagName)) {
el.style.backgroundColor = '';
el.style.color = '';
} else {
// revert form elements and buttons to default
if (el.tagName === 'BUTTON') {
el.style.backgroundColor = '#eee';
el.style.color = 'black';
}
if (['INPUT', 'SELECT', 'TEXTAREA'].includes(el.tagName)) {
el.style.backgroundColor = '#fff';
el.style.color = '#000';
}
}
});
}
}
// Apply last saved state when popup opens
applyInversion(isInverted);
// Toggle inversion on click
invertBtn.addEventListener('click', () => {
applyInversion(!isInverted);
});
// Toggle inversion on click
invertBtn.addEventListener('click', () => {
applyInversion(!isInverted);
});
// === Show/Hide Notes Button ===
const notesToggleBtn = document.createElement('button');
notesToggleBtn.textContent = 'Show Note'; // initial text
notesToggleBtn.style.marginLeft = '4px'; // spacing from previous button
notesToggleBtn.style.padding = '2px 4px';
// Apply consistent button style
styleButton(notesToggleBtn);
// Append next to Invert Colors
topBtnContainer.appendChild(notesToggleBtn);
const seriesId = window.seriesId || 'default-series';
const shownotesRaw = localStorage.getItem('shownotes') || '';
const shownotesSet = new Set(shownotesRaw.split(',').filter(Boolean));
let notesInitiallyVisible = shownotesSet.has(seriesId);
// Helper: show/hide ✎ only for this series
function updatePencils(show) {
document.querySelectorAll('span.text-patch.system_term[data-note]').forEach(span => {
const btn = span.nextElementSibling;
if (!btn || btn.textContent.trim() !== '✎') return;
const noteSeries = span.dataset.series || 'default-series';
if (noteSeries !== seriesId) return;
btn.style.display = show ? 'inline-block' : 'none';
});
}
// Initialize visibility
updatePencils(notesInitiallyVisible);
notesToggleBtn.textContent = notesInitiallyVisible ? 'Hide Note' : 'Show Note';
// Click handler
notesToggleBtn.addEventListener('click', () => {
const showing = notesToggleBtn.textContent === 'Hide Note';
const newShow = !showing;
updatePencils(newShow);
notesToggleBtn.textContent = newShow ? 'Hide Note' : 'Show Note';
if (newShow) shownotesSet.add(seriesId);
else shownotesSet.delete(seriesId);
localStorage.setItem('shownotes', Array.from(shownotesSet).join(','));
});
// Participate in inversion
function applyInversion(state) {
isInverted = state;
localStorage.setItem('replacementUIInverted', state);
const color = isInverted ? '#fff' : '#000';
const bg = isInverted ? '#333' : '#eee';
Array.from(topBtnContainer.querySelectorAll('button')).forEach(btn => {
btn.style.color = color;
btn.style.backgroundColor = bg;
});
popup.style.backgroundColor = isInverted ? '#000' : '#fff';
popup.style.color = color;
infoBox.style.backgroundColor = isInverted ? '#000' : '#fff';
infoBox.style.color = color;
}
// Apply last saved state
applyInversion(isInverted);
// Append button next to Invert Colors
topBtnContainer.appendChild(notesToggleBtn);
const rulesContainer = document.createElement('div');
rulesContainer.style.display = 'flex';
rulesContainer.style.flexWrap = 'wrap';
rulesContainer.style.gap = '10px';
rulesContainer.style.alignItems = 'center';
rulesContainer.style.marginBottom = '10px';
// Current flags state for the checkboxes
const currentFlags = {
ignoreCapital: false,
startOfSentence: false,
allInstances: false,
preserveFirstCapital: false,
global: false,
noTrailingSpace: false,
// New dialogue flags
insideDialogueOnly: false,
outsideDialogueOnly: false,
};
// Helper to create checkbox + label
function createCheckbox(flagKey, labelText) {
const label = document.createElement('label');
label.style.userSelect = 'none';
label.style.fontSize = '13px';
label.style.display = 'flex';
label.style.alignItems = 'center';
label.style.gap = '4px';
label.style.whiteSpace = 'nowrap';
label.style.flex = '0 1 auto'; // shrink if needed, don't force 100%
const input = document.createElement('input');
input.type = 'checkbox';
input.checked = currentFlags[flagKey];
input.style.cursor = 'pointer';
input.addEventListener('change', () => {
currentFlags[flagKey] = input.checked;
// If global toggled, optionally do something like disable series input if you have one
});
label.appendChild(input);
label.appendChild(document.createTextNode(labelText));
return label;
}
rulesContainer.appendChild(createCheckbox('ignoreCapital', 'Ignore Capital'));
rulesContainer.appendChild(createCheckbox('startOfSentence', 'Start of Sentence'));
rulesContainer.appendChild(createCheckbox('allInstances', 'Fuzzy Match'));
rulesContainer.appendChild(createCheckbox('preserveFirstCapital', 'Preserve Capital'));
rulesContainer.appendChild(createCheckbox('global', 'Global'));
rulesContainer.appendChild(createCheckbox('noTrailingSpace', 'No Trailing Space'));
rulesContainer.appendChild(createCheckbox('insideDialogueOnly', 'Edit Inside Dialogue'));
rulesContainer.appendChild(createCheckbox('outsideDialogueOnly', 'Edit Outside Dialogue'));
popup.appendChild(rulesContainer);
// Container that will hold search, filter, and list
const listUIContainer = document.createElement('div');
listUIContainer.style.display = 'none'; // hidden by default
popup.appendChild(listUIContainer);
// Search input
const searchInput = document.createElement('input');
searchInput.type = 'search';
searchInput.placeholder = 'Search terms...';
searchInput.style.width = '100%';
searchInput.style.marginBottom = '10px';
listUIContainer.appendChild(searchInput);
// Filter select
const toggleFilter = document.createElement('select');
['Current Series', 'Global + Others', 'All'].forEach(optText => {
const option = document.createElement('option');
option.textContent = optText;
toggleFilter.appendChild(option);
});
toggleFilter.style.width = '100%';
toggleFilter.style.marginBottom = '10px';
listUIContainer.appendChild(toggleFilter);
// Buttons container
const btnContainer = document.createElement('div');
btnContainer.style.marginBottom = '10px';
btnContainer.style.textAlign = 'right';
const exportBtn = document.createElement('button');
exportBtn.textContent = 'Export';
styleButton(exportBtn);
btnContainer.appendChild(exportBtn);
exportBtn.style.marginRight = '4px';
exportBtn.style.padding = '2px 4px';
const importBtn = document.createElement('button');
importBtn.textContent = 'Import';
styleButton(importBtn);
importBtn.style.marginLeft = '4px';
btnContainer.appendChild(importBtn);
importBtn.style.padding = '2px 4px';
// --- Create current-series-only buttons ---
const exportCurrentBtn = document.createElement('button');
exportCurrentBtn.textContent = 'Export Current';
styleButton(exportCurrentBtn);
exportCurrentBtn.style.marginRight = '4px'; // spacing to the right
exportCurrentBtn.style.padding = '2px 4px';
const importCurrentBtn = document.createElement('button');
importCurrentBtn.textContent = 'Import Current';
styleButton(importCurrentBtn);
importCurrentBtn.style.marginRight = '4px'; // spacing to the right
importCurrentBtn.style.padding = '2px 4px';
// --- Append current-series buttons to the existing container ---
// Ensure they appear to the left of the original buttons
btnContainer.insertBefore(exportCurrentBtn, exportBtn);
btnContainer.insertBefore(importCurrentBtn, exportBtn);
// --- Event listeners for current-series buttons ---
exportCurrentBtn.addEventListener('click', function() {
const seriesId = getCurrentSeriesId();
if (!seriesId) {
alert('No current series selected!');
return;
}
// Only export entries whose series matches current series ID
const exportData = [];
for (const key in data) {
(data[key] || []).forEach(entry => {
if (entry.series === seriesId) {
exportData.push(entry);
}
});
}
if (exportData.length === 0) {
alert('No entries found for the current series.');
return;
}
const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `word-replacer-series-${seriesId}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
});
importCurrentBtn.addEventListener('click', function() {
const seriesId = getCurrentSeriesId();
if (!seriesId) {
alert('No current series selected!');
return;
}
const seriesKey = `series-${seriesId}`;
if (!data[seriesKey]) data[seriesKey] = [];
const inputFile = document.createElement('input');
inputFile.type = 'file';
inputFile.accept = '.json,.txt';
inputFile.addEventListener('change', (e) => {
if (!e.target.files.length) return;
const file = e.target.files[0];
const reader = new FileReader();
reader.onload = (event) => {
try {
const parsed = JSON.parse(event.target.result);
if (!Array.isArray(parsed)) {
alert('Invalid format: must be an array of replacement entries.');
return;
}
// Assign all imported entries to current series
parsed.forEach(entry => {
if (!entry.from || !entry.to) return;
data[seriesKey].push({
...entry,
series: seriesId, // force current series
enabled: true,
});
});
saveData(data);
renderList();
replaceTextInChapter();
} catch (err) {
alert('Import failed: ' + err.message);
}
};
reader.readAsText(file);
});
inputFile.click();
});
listUIContainer.appendChild(btnContainer);
// List container
const listContainer = document.createElement('div');
listContainer.style.maxHeight = '260px';
listContainer.style.overflowY = 'auto';
listContainer.style.borderTop = '1px solid #ddd';
listContainer.style.paddingTop = '8px';
listUIContainer.appendChild(listContainer);
// Style buttons helper
function styleButton(btn) {
btn.style.padding = '5px 12px';
btn.style.fontSize = '13px';
btn.style.cursor = 'pointer';
btn.style.border = '1px solid #888';
btn.style.borderRadius = '4px';
btn.style.backgroundColor = '#eee';
btn.style.userSelect = 'none';
}
toggleListBtn.addEventListener('click', () => {
const isShowing = listUIContainer.style.display !== 'none';
if (!isShowing) {
// Show the list, hide the rules
listUIContainer.style.display = 'block';
rulesContainer.style.display = 'none';
toggleListBtn.textContent = 'Hide List';
renderList(); // refresh list when showing
} else {
// Hide the list, show the rules
listUIContainer.style.display = 'none';
rulesContainer.style.display = 'flex';
toggleListBtn.textContent = 'Show List';
}
});
// Get current series id helper
function getCurrentSeriesId() {
// Match new URL structure: /novel/{id}/
const urlMatch = location.href.match(/\/novel\/(\d+)\//i);
if (urlMatch) return urlMatch[1];
// Fallback: check breadcrumb links
const crumb = document.querySelector('.breadcrumb li.breadcrumb-item a[href*="/novel/"]');
if (crumb) {
const crumbMatch = crumb.href.match(/\/novel\/(\d+)\//i);
if (crumbMatch) return crumbMatch[1];
}
return null;
}
// Render list of replacements
function renderList() {
listContainer.innerHTML = '';
const seriesId = getCurrentSeriesId();
let keysToShow = [];
if (toggleFilter.value === 'Current Series') {
if (seriesId) keysToShow = [`series-${seriesId}`];
else keysToShow = [];
} else if (toggleFilter.value === 'Global + Others') {
keysToShow = Object.keys(data).filter(k => k !== `series-${seriesId}`);
} else { // All
keysToShow = Object.keys(data);
}
let allEntries = [];
keysToShow.forEach(key => {
if (data[key]) allEntries = allEntries.concat(data[key]);
});
const searchLower = searchInput.value.trim().toLowerCase();
if (searchLower) {
allEntries = allEntries.filter(e =>
(e.from && e.from.toLowerCase().includes(searchLower)) ||
(e.to && e.to.toLowerCase().includes(searchLower))
);
}
if (allEntries.length === 0) {
const emptyMsg = document.createElement('div');
emptyMsg.textContent = 'No terms found.';
emptyMsg.style.fontStyle = 'italic';
listContainer.appendChild(emptyMsg);
return;
}
allEntries.forEach((entry) => {
const row = document.createElement('div');
row.style.display = 'flex';
row.style.alignItems = 'flex-start'; // align top when text wraps
row.style.justifyContent = 'space-between'; // space between left text and right controls
row.style.marginBottom = '6px';
row.style.width = '100%';
// ---- Left side (text) ----
const textContainer = document.createElement('div');
textContainer.style.display = 'flex';
textContainer.style.flexDirection = 'row';
textContainer.style.flexWrap = 'wrap';
textContainer.style.flexGrow = '1';
textContainer.style.minWidth = '0'; // needed for wrapping
const fromSpan = document.createElement('span');
fromSpan.textContent = entry.from;
fromSpan.style.cursor = 'pointer';
fromSpan.style.userSelect = 'none';
fromSpan.style.color = '#007bff';
fromSpan.style.wordBreak = 'break-word';
fromSpan.style.overflowWrap = 'anywhere';
fromSpan.addEventListener('click', () => {
openEditDialog(entry);
});
const toSpan = document.createElement('span');
toSpan.textContent = ' → ' + entry.to;
toSpan.style.marginLeft = '8px';
toSpan.style.wordBreak = 'break-word';
toSpan.style.overflowWrap = 'anywhere';
textContainer.appendChild(fromSpan);
textContainer.appendChild(toSpan);
// ---- Right side (controls) ----
const controls = document.createElement('div');
controls.style.display = 'flex';
controls.style.alignItems = 'center';
controls.style.flexShrink = '0'; // don’t shrink controls
controls.style.marginLeft = '12px';
const enabledCheckbox = document.createElement('input');
enabledCheckbox.type = 'checkbox';
enabledCheckbox.checked = entry.enabled ?? true;
enabledCheckbox.title = 'Enable / Disable this replacement';
enabledCheckbox.style.marginRight = '8px';
enabledCheckbox.addEventListener('change', () => {
entry.enabled = enabledCheckbox.checked;
saveData(data);
replaceTextInChapter();
});
const deleteBtn = document.createElement('button');
deleteBtn.textContent = '✕';
styleButton(deleteBtn);
deleteBtn.title = 'Delete this replacement';
deleteBtn.addEventListener('click', () => {
deleteEntry(entry);
});
controls.appendChild(enabledCheckbox);
controls.appendChild(deleteBtn);
// ---- Combine ----
row.appendChild(textContainer);
row.appendChild(controls);
listContainer.appendChild(row);
});
}
// Delete entry
function deleteEntry(entry) {
for (const key in data) {
const arr = data[key];
const idx = arr.findIndex(e => e.from === entry.from);
if (idx >= 0) {
arr.splice(idx, 1);
if (arr.length === 0 && key !== 'global') {
delete data[key];
}
saveData(data);
renderList();
replaceTextInChapter();
break;
}
}
}
// Edit dialog popup
function openEditDialog(entry) {
const modalBg = document.createElement('div');
Object.assign(modalBg.style, {
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0,0,0,0.5)',
zIndex: 100001,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
});
const modal = document.createElement('div');
Object.assign(modal.style, {
backgroundColor: isInverted ? '#000' : 'white',
color: isInverted ? '#fff' : '#000',
padding: '20px',
borderRadius: '8px',
width: '320px',
boxShadow: '0 0 15px rgba(0,0,0,0.3)',
fontSize: '14px',
});
modalBg.appendChild(modal);
// Title
const title = document.createElement('h3');
title.textContent = 'Edit Replacement';
title.style.marginTop = '0';
modal.appendChild(title);
// From input
const fromLabel = document.createElement('label');
fromLabel.textContent = 'Find: ';
const fromInput = document.createElement('input');
fromInput.type = 'text';
fromInput.value = entry.from;
fromInput.style.width = '100%';
fromInput.required = true;
fromLabel.appendChild(fromInput);
modal.appendChild(fromLabel);
modal.appendChild(document.createElement('br'));
// To input
const toLabel = document.createElement('label');
toLabel.textContent = 'Replace with: ';
const toInput = document.createElement('input');
toInput.type = 'text';
toInput.value = entry.to;
toInput.style.width = '100%';
toLabel.appendChild(toInput);
modal.appendChild(toLabel);
modal.appendChild(document.createElement('br'));
const noteBtn = document.createElement('button');
noteBtn.textContent = entry.note ? 'Edit Note' : 'Add Note';
noteBtn.style.marginTop = '8px';
noteBtn.style.display = 'block';
modal.appendChild(noteBtn);
noteBtn.addEventListener('click', () => openNoteModal(entry, noteBtn));
function openNoteModal(entry, buttonRef) {
const noteModalBg = document.createElement('div');
Object.assign(noteModalBg.style, {
position: 'fixed',
top: 0,
left: 0,
width: '100vw',
height: '100vh',
backgroundColor: 'rgba(0,0,0,0.4)',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
zIndex: 999999,
});
const noteModal = document.createElement('div');
Object.assign(noteModal.style, {
backgroundColor: 'black',
color: 'black',
padding: '20px',
borderRadius: '8px',
width: '280px',
boxShadow: '0 0 15px rgba(0,0,0,0.3)',
fontSize: '14px',
});
noteModalBg.appendChild(noteModal);
const noteTitle = document.createElement('h3');
noteTitle.textContent = 'Add Note';
noteTitle.style.marginTop = '0';
noteModal.appendChild(noteTitle);
noteTitle.style.color = 'white';
const noteInput = document.createElement('textarea');
noteInput.rows = 3;
noteInput.maxLength = 30;
noteInput.value = entry.note || '';
noteInput.style.width = '100%';
noteInput.placeholder = 'Enter a short note';
noteModal.appendChild(noteInput);
const noteSave = document.createElement('button');
noteSave.textContent = 'Save';
noteSave.style.marginRight = '10px';
const noteCancel = document.createElement('button');
noteCancel.textContent = 'Cancel';
noteModal.appendChild(noteSave);
noteModal.appendChild(noteCancel);
document.body.appendChild(noteModalBg);
noteSave.addEventListener('click', () => {
entry.note = noteInput.value.trim().slice(0, 30);
if (buttonRef) buttonRef.textContent = entry.note ? 'Edit Note' : 'Add Note';
document.body.removeChild(noteModalBg);
});
noteCancel.addEventListener('click', () => {
document.body.removeChild(noteModalBg);
});
}
// Enabled checkbox
const enabledLabel = document.createElement('label');
const enabledInput = document.createElement('input');
enabledInput.type = 'checkbox';
enabledInput.checked = entry.enabled ?? true;
enabledLabel.appendChild(enabledInput);
enabledLabel.append(' Enabled');
enabledLabel.style.userSelect = 'none';
modal.appendChild(enabledLabel);
modal.appendChild(document.createElement('br'));
// Flags container
const flags = [
{ key: 'ignoreCapital', label: 'Ignore Capitalization' },
{ key: 'startOfSentence', label: 'Match Whether Start of Sentence' },
{ key: 'allInstances', label: 'Fuzzy Match' },
{ key: 'preserveFirstCapital', label: 'Preserve First Capital Letter' },
{ key: 'noTrailingSpace', label: 'No Trailing Space' },
{ key: 'insideDialogueOnly', label: 'Edit Only Inside Dialogue' },
{ key: 'outsideDialogueOnly', label: 'Edit Only Outside Dialogue' },
];
flags.forEach(f => {
const flagLabel = document.createElement('label');
const flagInput = document.createElement('input');
flagInput.type = 'checkbox';
flagInput.checked = entry[f.key] ?? false;
flagLabel.appendChild(flagInput);
flagLabel.append(' ' + f.label);
flagLabel.style.display = 'block';
flagLabel.style.userSelect = 'none';
modal.appendChild(flagLabel);
flagInput.addEventListener('change', () => {
entry[f.key] = flagInput.checked;
});
});
modal.appendChild(document.createElement('br'));
// Series input (optional)
const seriesLabel = document.createElement('label');
seriesLabel.textContent = 'Series ID (empty = global): ';
const seriesInput = document.createElement('input');
seriesInput.type = 'text';
seriesInput.value = entry.series || '';
seriesInput.style.width = '100%';
seriesLabel.appendChild(seriesInput);
modal.appendChild(seriesLabel);
modal.appendChild(document.createElement('br'));
// Buttons
const btnSave = document.createElement('button');
btnSave.textContent = 'Save';
btnSave.style.marginRight = '10px';
const btnCancel = document.createElement('button');
btnCancel.textContent = 'Cancel';
modal.appendChild(btnSave);
modal.appendChild(btnCancel);
// Save handler
btnSave.addEventListener('click', () => {
let f = fromInput.value;
const t = toInput.value;
// Only trim the search word if this entry has noTrailingSpace enabled
if (entry.noTrailingSpace) {
f = f.trim();
}
// Update entry properties
// If series changed, move to correct group
const oldSeriesKey = entry.series ? `series-${entry.series}` : 'global';
const newSeriesKey = seriesInput.value ? `series-${seriesInput.value}` : 'global';
if (oldSeriesKey !== newSeriesKey) {
// Remove from old array
if (data[oldSeriesKey]) {
const idx = data[oldSeriesKey].indexOf(entry);
if (idx >= 0) data[oldSeriesKey].splice(idx, 1);
if (data[oldSeriesKey].length === 0 && oldSeriesKey !== 'global') {
delete data[oldSeriesKey];
}
}
// Add to new array
if (!data[newSeriesKey]) data[newSeriesKey] = [];
data[newSeriesKey].push(entry);
entry.series = seriesInput.value.trim();
}
entry.from = f;
entry.to = t;
entry.enabled = enabledInput.checked;
saveData(data);
renderList();
replaceTextInChapter();
closeEditModal();
});
btnCancel.addEventListener('click', () => {
closeEditModal();
});
function closeEditModal() {
modalBg.remove();
}
document.body.appendChild(modalBg);
}
// Add new entry controls at bottom
const addNewLabel = document.createElement('div');
addNewLabel.textContent = 'Add New Replacement:';
addNewLabel.style.marginTop = '15px';
addNewLabel.style.fontWeight = 'bold';
popup.appendChild(addNewLabel);
const inputContainer = document.createElement('div');
inputContainer.style.display = 'flex';
inputContainer.style.gap = '6px';
inputContainer.style.marginTop = '6px';
inputContainer.style.flexWrap = 'nowrap'; // keep inputs + button on one line
inputContainer.style.alignItems = 'center'; // vertical alignment
const fromInputNew = document.createElement('input');
fromInputNew.placeholder = 'Find';
fromInputNew.style.flex = '1'; // take available width
fromInputNew.style.minWidth = '60px'; // prevent shrinking too much
inputContainer.appendChild(fromInputNew);
const toInputNew = document.createElement('input');
toInputNew.placeholder = 'Replace with';
toInputNew.style.flex = '1';
toInputNew.style.minWidth = '60px';
inputContainer.appendChild(toInputNew);
// Autocomplete suggestion box for "Replace with"
const replaceSuggestionBox = document.createElement('ul');
Object.assign(replaceSuggestionBox.style, {
position: 'absolute',
zIndex: 9999,
border: '1px solid #ccc',
background: '#000', // solid black background
color: '#fff', // white text
listStyle: 'none',
margin: 0,
padding: 0,
maxHeight: '120px',
overflowY: 'auto',
display: 'none',
opacity: '1', // fully opaque
});
inputContainer.appendChild(replaceSuggestionBox);
// Helper to position suggestion box above the input
function positionReplaceBox() {
// Make it visible temporarily to measure its height
replaceSuggestionBox.style.display = 'block';
const rect = toInputNew.getBoundingClientRect();
const containerRect = inputContainer.getBoundingClientRect();
// Place the bottom of the box at the top of the input
replaceSuggestionBox.style.left = (toInputNew.offsetLeft) + 'px';
replaceSuggestionBox.style.top = (toInputNew.offsetTop - replaceSuggestionBox.offsetHeight) + 'px';
}
// Input listener
toInputNew.addEventListener('input', () => {
const val = toInputNew.value.trim().toLowerCase();
replaceSuggestionBox.innerHTML = '';
if (val.length < 2) {
replaceSuggestionBox.style.display = 'none';
return;
}
// Pull all "to" values from data across all series + global
const allTerms = Object.values(data)
.flat()
.map(entry => entry.to)
.filter((v, i, self) => v && self.indexOf(v) === i); // unique & non-empty
const matches = allTerms.filter(term => term.toLowerCase().includes(val));
if (!matches.length) {
replaceSuggestionBox.style.display = 'none';
return;
}
matches.forEach(term => {
const li = document.createElement('li');
li.textContent = term;
li.style.padding = '4px 6px';
li.style.cursor = 'pointer';
li.style.background = '#000'; // default black background
li.style.color = '#fff'; // default white text
li.addEventListener('mousedown', (e) => {
e.preventDefault(); // prevent blur
toInputNew.value = term;
replaceSuggestionBox.style.display = 'none';
});
// Remove the previous hover effects
li.addEventListener('mouseover', () => {
li.style.background = '#111'; // slightly lighter black on hover
});
li.addEventListener('mouseout', () => {
li.style.background = '#000'; // back to solid black
});
replaceSuggestionBox.appendChild(li);
});
positionReplaceBox();
replaceSuggestionBox.style.display = 'block';
});
// Hide suggestions when clicking outside
document.addEventListener('click', (e) => {
if (!inputContainer.contains(e.target)) {
replaceSuggestionBox.style.display = 'none';
}
});
const addBtn = document.createElement('button');
addBtn.textContent = 'Add';
styleButton(addBtn);
addBtn.addEventListener('click', () => {
let f = fromInputNew.value;
const t = toInputNew.value;
// Check the actual checkbox state at creation time
const noTrailingSpaceChecked = document.querySelector('#noTrailingSpaceCheckboxId')?.checked;
if (noTrailingSpaceChecked) {
f = f.trim();
}
if (!f) {
alert('Find term cannot be empty');
return;
}
// Use global checkbox to decide seriesId: empty means global
const seriesId = currentFlags.global ? '' : getCurrentSeriesId();
const seriesKey = seriesId ? `series-${seriesId}` : 'global';
if (!data[seriesKey]) data[seriesKey] = [];
// Avoid duplicates in that series/global bucket
if (data[seriesKey].some(e => e.from.toLowerCase() === f.toLowerCase())) {
alert('This find term already exists in this series/global.');
return;
}
data[seriesKey].push({
from: f,
to: t,
note: '', // <-- new
enabled: true,
ignoreCapital: currentFlags.ignoreCapital,
startOfSentence: currentFlags.startOfSentence,
allInstances: currentFlags.allInstances,
preserveFirstCapital: currentFlags.preserveFirstCapital,
series: seriesId || '',
noTrailingSpace: currentFlags.noTrailingSpace,
insideDialogueOnly: currentFlags.insideDialogueOnly,
outsideDialogueOnly: currentFlags.outsideDialogueOnly,
});
saveData(data);
fromInputNew.value = '';
toInputNew.value = '';
renderList();
replaceTextInChapter();
});
inputContainer.appendChild(fromInputNew);
inputContainer.appendChild(toInputNew);
inputContainer.appendChild(addBtn);
popup.appendChild(inputContainer);
// Export button
exportBtn.addEventListener('click', () => {
const dataStr = JSON.stringify(data, null, 2);
const blob = new Blob([dataStr], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'word-replacer-data.json';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
});
// Import button
importBtn.addEventListener('click', () => {
const inputFile = document.createElement('input');
inputFile.type = 'file';
inputFile.accept = '.json,.txt';
inputFile.addEventListener('change', (e) => {
if (!e.target.files.length) return;
const file = e.target.files[0];
const reader = new FileReader();
function importData(parsed) {
if (typeof parsed === 'object' && !Array.isArray(parsed)) {
// Assume full structured data (keys like 'global', 'series-...')
// Merge imported data with existing data (append pairs)
for (const key in parsed) {
if (!data[key]) data[key] = [];
// Append new entries (avoid duplicates if needed)
parsed[key].forEach(newEntry => data[key].push(newEntry));
}
} else if (Array.isArray(parsed)) {
// Array of pairs (old simple format)
if (!data.global) data.global = [];
const newPairs = parsed.map(pair => {
if (!Array.isArray(pair) || pair.length < 2) return null;
return {
from: pair[0],
to: pair[1],
enabled: true,
startOfSentence: false,
ignoreCapital: false,
allInstances: false,
preserveFirstCapital: false,
global: true,
seriesId: ''
};
}).filter(Boolean);
data.global.push(...newPairs);
} else {
alert('Import failed: unsupported format.');
return;
}
saveData(data);
renderList();
replaceTextInChapter();
}
reader.onload = (e) => {
try {
const parsed = JSON.parse(e.target.result);
importData(parsed);
alert('Import successful!');
} catch (err) {
alert('Invalid JSON: ' + err.message);
}
};
reader.readAsText(file);
});
inputFile.click();
});
// Search and filter event handlers
searchInput.addEventListener('input', renderList);
toggleFilter.addEventListener('change', renderList);
renderList();
document.body.appendChild(popup);
}
// Run once immediately
startReplaceLoop();
})();