Files
captionninja/text-rules.js

456 lines
12 KiB
JavaScript

(function (w) {
'use strict';
function toArray(value) {
return Array.isArray(value) ? value : [];
}
function escapeRegex(value) {
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function normalizeSpace(text) {
return String(text || '').replace(/\s+/g, ' ').trim();
}
function normalizeToken(text) {
return normalizeSpace(text).toLowerCase();
}
function parseList(text) {
if (!text) return [];
return String(text)
.replace(/\r/g, '\n')
.split(/[\n,;]+/)
.map(function (item) {
return normalizeSpace(item);
})
.filter(Boolean);
}
function parseMappings(text) {
if (!text) return [];
var lines = String(text).replace(/\r/g, '\n').split(/[\n,;]+/);
var out = [];
for (var i = 0; i < lines.length; i++) {
var line = lines[i].trim();
if (!line) continue;
var parts;
if (line.indexOf('=>') !== -1) {
parts = line.split('=>');
} else if (line.indexOf('->') !== -1) {
parts = line.split('->');
} else if (line.indexOf('=') !== -1) {
parts = line.split('=');
} else {
continue;
}
if (parts.length < 2) continue;
var from = normalizeSpace(parts[0]);
var to = normalizeSpace(parts.slice(1).join('=>'));
if (!from || !to) continue;
out.push({ from: from, to: to });
}
return out;
}
function decodeBase64Url(input) {
try {
var b64 = input.replace(/-/g, '+').replace(/_/g, '/');
while (b64.length % 4) {
b64 += '=';
}
if (typeof atob !== 'function') return '';
return atob(b64);
} catch (e) {
return '';
}
}
function decodeRulesParam(raw) {
if (!raw) return null;
var attempts = [];
attempts.push(raw);
try {
attempts.push(decodeURIComponent(raw));
} catch (e) {
// ignore
}
var decodedB64 = decodeBase64Url(raw);
if (decodedB64) attempts.push(decodedB64);
for (var i = 0; i < attempts.length; i++) {
try {
var parsed = JSON.parse(attempts[i]);
if (parsed && typeof parsed === 'object') {
return parsed;
}
} catch (e) {
// try next decode format
}
}
return null;
}
function preserveCase(source, target) {
if (!source || !target) return target;
if (source === source.toUpperCase()) {
return target.toUpperCase();
}
if (source === source.toLowerCase()) {
return target;
}
if (source.charAt(0) === source.charAt(0).toUpperCase() && source.slice(1) === source.slice(1).toLowerCase()) {
return target.charAt(0).toUpperCase() + target.slice(1);
}
return target;
}
function maskWord(word) {
if (!word) return word;
if (word.length <= 2) {
return '*'.repeat(word.length);
}
return word.charAt(0) + '*'.repeat(word.length - 2) + word.charAt(word.length - 1);
}
function maskPhrase(text) {
return String(text).replace(/[A-Za-z0-9]+/g, function (word) {
return maskWord(word);
});
}
function soundex(word) {
var upper = String(word || '').toUpperCase().replace(/[^A-Z]/g, '');
if (!upper) return '';
var map = {
B: '1', F: '1', P: '1', V: '1',
C: '2', G: '2', J: '2', K: '2', Q: '2', S: '2', X: '2', Z: '2',
D: '3', T: '3',
L: '4',
M: '5', N: '5',
R: '6'
};
var out = upper.charAt(0);
var last = map[out] || '';
for (var i = 1; i < upper.length && out.length < 4; i++) {
var c = upper.charAt(i);
var code = map[c] || '0';
if (code !== '0' && code !== last) {
out += code;
}
last = code;
}
while (out.length < 4) {
out += '0';
}
return out;
}
function levenshtein(a, b) {
if (a === b) return 0;
if (!a.length) return b.length;
if (!b.length) return a.length;
var v0 = new Array(b.length + 1);
var v1 = new Array(b.length + 1);
for (var i = 0; i <= b.length; i++) {
v0[i] = i;
}
for (var ai = 0; ai < a.length; ai++) {
v1[0] = ai + 1;
for (var bi = 0; bi < b.length; bi++) {
var cost = a.charAt(ai) === b.charAt(bi) ? 0 : 1;
v1[bi + 1] = Math.min(
v1[bi] + 1,
v0[bi + 1] + 1,
v0[bi] + cost
);
}
for (var j = 0; j <= b.length; j++) {
v0[j] = v1[j];
}
}
return v1[b.length];
}
function parseRulesFromUrlParams(urlParams) {
var cfg = {
replacements: [],
restricted: [],
matchMode: 'phonetic'
};
if (!urlParams || typeof urlParams.get !== 'function') {
return cfg;
}
var rulesRaw = urlParams.get('rules');
var decodedRules = decodeRulesParam(rulesRaw);
if (decodedRules) {
cfg.replacements = cfg.replacements.concat(
toArray(decodedRules.replacements).map(function (item) {
if (!item || typeof item !== 'object') return null;
var from = normalizeSpace(item.from);
var to = normalizeSpace(item.to);
if (!from || !to) return null;
return { from: from, to: to };
}).filter(Boolean)
);
cfg.restricted = cfg.restricted.concat(parseList(toArray(decodedRules.restricted).join('\n')));
if (decodedRules.matchMode) {
cfg.matchMode = String(decodedRules.matchMode).toLowerCase();
}
}
var mappingParamNames = ['map', 'maps', 'mapping', 'replace', 'replacements'];
for (var i = 0; i < mappingParamNames.length; i++) {
var mapRaw = urlParams.get(mappingParamNames[i]);
if (mapRaw) {
cfg.replacements = cfg.replacements.concat(parseMappings(mapRaw));
}
}
var restrictedParamNames = ['restricted', 'block', 'blocked', 'ban', 'banned'];
for (var r = 0; r < restrictedParamNames.length; r++) {
var listRaw = urlParams.get(restrictedParamNames[r]);
if (listRaw) {
cfg.restricted = cfg.restricted.concat(parseList(listRaw));
}
}
var explicitMatch = urlParams.get('match') || urlParams.get('matchmode');
if (explicitMatch) {
cfg.matchMode = String(explicitMatch).toLowerCase();
}
if (cfg.matchMode !== 'phonetic' && cfg.matchMode !== 'word' && cfg.matchMode !== 'substring') {
cfg.matchMode = 'phonetic';
}
// Deduplicate replacements by normalized "from"
var seenFrom = Object.create(null);
cfg.replacements = cfg.replacements.filter(function (entry) {
if (!entry || !entry.from || !entry.to) return false;
var key = normalizeToken(entry.from);
if (!key) return false;
if (seenFrom[key]) return false;
seenFrom[key] = true;
return true;
});
// Deduplicate restricted words
var seenRestricted = Object.create(null);
cfg.restricted = cfg.restricted.filter(function (term) {
var key = normalizeToken(term);
if (!key) return false;
if (seenRestricted[key]) return false;
seenRestricted[key] = true;
return true;
});
return cfg;
}
function createEngine(config) {
config = config || {};
var replacements = toArray(config.replacements).filter(function (entry) {
return entry && entry.from && entry.to;
}).map(function (entry) {
return {
from: normalizeSpace(entry.from),
to: normalizeSpace(entry.to),
fromNorm: normalizeToken(entry.from)
};
}).filter(function (entry) {
return entry.from && entry.to;
});
replacements.sort(function (a, b) {
return b.from.length - a.from.length;
});
var replacementLookup = Object.create(null);
replacements.forEach(function (entry) {
replacementLookup[entry.fromNorm] = entry.to;
});
var fuzzyCandidates = replacements
.filter(function (entry) {
return entry.from.indexOf(' ') === -1 && entry.fromNorm.length >= 3;
})
.map(function (entry) {
return {
from: entry.from,
fromNorm: entry.fromNorm,
to: entry.to,
sound: soundex(entry.fromNorm),
first: entry.fromNorm.charAt(0)
};
});
var restricted = toArray(config.restricted)
.map(function (term) {
return normalizeSpace(term);
})
.filter(Boolean)
.sort(function (a, b) {
return b.length - a.length;
});
var mode = (config.matchMode || 'phonetic').toLowerCase();
if (mode !== 'phonetic' && mode !== 'word' && mode !== 'substring') {
mode = 'phonetic';
}
function applyExactMappings(text) {
var out = text;
for (var i = 0; i < replacements.length; i++) {
var entry = replacements[i];
if (!entry.from) continue;
var escaped = escapeRegex(entry.from);
var regex;
if (mode === 'substring') {
regex = new RegExp(escaped, 'gi');
} else {
regex = new RegExp('(^|[^A-Za-z0-9])(' + escaped + ')(?=$|[^A-Za-z0-9])', 'gi');
}
out = out.replace(regex, function (match, prefix, value) {
if (mode === 'substring') {
return preserveCase(match, entry.to);
}
var safePrefix = prefix || '';
return safePrefix + preserveCase(value, entry.to);
});
}
return out;
}
function applyFuzzyMappings(text) {
if (mode !== 'phonetic' || !fuzzyCandidates.length) {
return text;
}
return text.replace(/\b([A-Za-z][A-Za-z0-9'_-]*)\b/g, function (matchWord) {
var normalized = normalizeToken(matchWord);
if (!normalized || normalized.length < 3) {
return matchWord;
}
if (replacementLookup[normalized]) {
// Exact map exists; preserve exact mapping precedence.
return matchWord;
}
var targetSound = soundex(normalized);
var best = null;
for (var i = 0; i < fuzzyCandidates.length; i++) {
var candidate = fuzzyCandidates[i];
if (candidate.first !== normalized.charAt(0)) {
continue;
}
var dist = levenshtein(normalized, candidate.fromNorm);
var threshold = normalized.length <= 5 ? 1 : 2;
var soundMatch = candidate.sound && targetSound && candidate.sound === targetSound;
if (!soundMatch && dist > threshold) {
continue;
}
var score = dist + (soundMatch ? 0 : 2);
if (!best || score < best.score) {
best = { to: candidate.to, score: score };
}
}
if (!best) {
return matchWord;
}
return preserveCase(matchWord, best.to);
});
}
function applyRestrictedMasking(text) {
var out = text;
for (var i = 0; i < restricted.length; i++) {
var phrase = restricted[i];
if (!phrase) continue;
var escaped = escapeRegex(phrase);
var regex = new RegExp('(^|[^A-Za-z0-9])(' + escaped + ')(?=$|[^A-Za-z0-9])', 'gi');
out = out.replace(regex, function (match, prefix, value) {
var safePrefix = prefix || '';
return safePrefix + maskPhrase(value);
});
}
return out;
}
function apply(text) {
if (text === null || text === undefined) {
return text;
}
var out = String(text);
if (!out) return out;
out = applyExactMappings(out);
out = applyFuzzyMappings(out);
out = applyRestrictedMasking(out);
return out;
}
function applyPayload(payload) {
if (!payload || typeof payload !== 'object') {
return payload;
}
var out = payload;
if (typeof out.final === 'string') {
out.final = apply(out.final);
}
if (typeof out.interm === 'string') {
out.interm = apply(out.interm);
}
return out;
}
return {
apply: apply,
applyPayload: applyPayload,
hasRules: function () {
return replacements.length > 0 || restricted.length > 0;
},
summary: function () {
return {
matchMode: mode,
replacements: replacements.length,
restricted: restricted.length
};
}
};
}
w.CaptionTextRules = {
parseRulesFromUrlParams: parseRulesFromUrlParams,
createEngine: createEngine,
decodeRulesParam: decodeRulesParam,
parseMappings: parseMappings,
parseList: parseList
};
})(window);