1/** 2 * GriGsi Space â compose sanity check before send. 3 * Detects likely mobile selection-overwrite accidents (merged sentences, frankenwords, etc.). 4 */ 5(function () { 6 'use strict'; 7 8 var BRAND_INTERNAL_CAPS = /^(i[A-Z]|Mac[A-Z]|Mc[A-Z]|O[A-Z']|eBay|YouTube|LinkedIn|GitHub|PayPal|WhatsApp|FaceBook|FaceTime)/; 9 10 function lettersOnly(word) { 11 return String(word || '').replace(/[^\p{L}]/gu, ''); 12 } 13 14 function isUrlish(text) { 15 var t = String(text || '').trim(); 16 return /^https?:\/\//i.test(t) || /^www\./i.test(t) || /^[a-z0-9+.-]+@[a-z0-9.-]+\.[a-z]{2,}$/i.test(t); 17 } 18 19 function hasInternalCaps(word) { 20 var letters = lettersOnly(word); 21 if (letters.length < 4) return false; 22 if (BRAND_INTERNAL_CAPS.test(letters)) return false; 23 return /[\p{Ll}][\p{Lu}]/u.test(letters); 24 } 25 26 function hasMixedScript(word) { 27 var letters = lettersOnly(word); 28 if (letters.length < 3) return false; 29 return /[A-Za-z]/.test(letters) && /[\u0400-\u04FF]/.test(letters); 30 } 31 32 function findPhraseOverlap(text) { 33 var s = String(text || '').replace(/\s+/g, ' ').trim(); 34 if (s.length < 16) return null; 35 var maxLen = Math.min(28, Math.floor(s.length / 2)); 36 for (var len = maxLen; len >= 8; len--) { 37 for (var i = 0; i <= s.length - len; i++) { 38 var sub = s.slice(i, i + len); 39 if (!/[ \p{L}]/u.test(sub)) continue; 40 var trimmed = sub.trim(); 41 if (trimmed.length < 8) continue; 42 // Only match on word boundaries. Without this, two unrelated words 43 // that merely start with the same few letters (very common between 44 // inflected Bulgarian words, e.g. "ÑÑаÑеÑо" / "ÑÑакиÑ" both 45 // starting with "ÑÑа") get flagged as an accidental duplicate â 46 // a real copy/overwrite mistake repeats whole words, not an 47 // arbitrary shared prefix/suffix fragment. 48 if (i > 0 && s[i - 1] !== ' ') continue; 49 if (i + len < s.length && s[i + len] !== ' ') continue; 50 var j = s.indexOf(sub, i + 2); 51 if (j === -1) continue; 52 if (j > 0 && s[j - 1] !== ' ') continue; 53 if (j + len < s.length && s[j + len] !== ' ') continue; 54 var gap = j - (i + len); 55 if (gap >= 0 && gap <= 36) { 56 var between = s.slice(i + len, j).trim(); 57 if (between.length > 0 && between.length <= 32) return trimmed; 58 } 59 } 60 } 61 return null; 62 } 63 64 function findWordMerge(words) { 65 for (var i = 0; i < words.length - 1; i++) { 66 var a = lettersOnly(words[i]).toLowerCase(); 67 var b = lettersOnly(words[i + 1]).toLowerCase(); 68 if (a.length < 4 || b.length < 4) continue; 69 var longer = a.length >= b.length ? a : b; 70 var shorter = a.length >= b.length ? b : a; 71 if (longer.indexOf(shorter) !== -1 && longer !== shorter) { 72 return words[i] + ' ' + words[i + 1]; 73 } 74 for (var k = 4; k <= Math.min(a.length, b.length); k++) { 75 if (a.slice(-k) === b.slice(0, k)) return words[i] + ' ' + words[i + 1]; 76 } 77 } 78 return null; 79 } 80 81 function findMidSentenceRestart(text) { 82 var m = String(text || '').match(/[^.!?;:\n][)\]]?\s+([\p{Lu}][\p{Ll}]{2,})/u); 83 if (!m) return null; 84 var word = m[1]; 85 if (/^(I|Az|Ðз|Ti|Ти|To|Той|Ta|ТÑ|Te|Те|We|Ðие|You|Ðие|He|She|It|They|Те)$/u.test(word)) return null; 86 return word; 87 } 88 89 function vowelRatio(letters) { 90 if (!letters.length) return 1; 91 var vowels = (letters.match(/[aeiouyаеиоÑÑÑÑÑÑÑÑ]/gi) || []).length; 92 return vowels / letters.length; 93 } 94 95 var REASON_META = { 96 internal_caps: { score: 3, en: 'Unexpected capital letter inside a word', bg: 'ÐеоÑаквана главна бÑква в дÑмаÑа' }, 97 mixed_script: { score: 4, en: 'Latin and Cyrillic mixed in one word', bg: 'СмеÑени лаÑиниÑа и киÑилиÑа в една дÑма' }, 98 overlap: { score: 4, en: 'Repeated phrase â text may be overwritten', bg: 'ÐовÑаÑÑÑ Ñе ÑÑÐ°Ð³Ð¼ÐµÐ½Ñ â веÑоÑÑно пÑезапиÑан ÑекÑÑ' }, 99 word_merge: { score: 4, en: 'Two words look merged together', bg: 'Ðве дÑми Ð¸Ð·Ð³Ð»ÐµÐ¶Ð´Ð°Ñ Ñлеени' }, 100 long_word: { score: 3, en: 'Unusually long word without spaces', bg: 'ÐеобиÑайно дÑлга дÑма без инÑеÑвал' }, 101 mid_restart: { score: 2, en: 'New sentence starts without punctuation', bg: 'Ðово изÑеÑение без ÑоÑка или запеÑаÑ' }, 102 low_vowel: { score: 2, en: 'Word looks garbled (few vowels)', bg: 'ÐÑмаÑа изглежда обÑÑкана (малко глаÑни)' }, 103 }; 104 105 function analyze(text) { 106 var t = String(text || '').trim(); 107 var reasons = []; 108 if (t.length < 10) return { suspicious: false, score: 0, reasons: [] }; 109 if (isUrlish(t)) return { suspicious: false, score: 0, reasons: [] }; 110 if (/^[\+\-\*\/\=\<\>\(\)\[\]\{\}\|&\^%#@~`]+$/.test(t)) return { suspicious: false, score: 0, reasons: [] }; 111
112 var words = t.split(/\s+/).filter(Boolean); 113 if (words.length < 2 && t.length < 24) return { suspicious: false, score: 0, reasons: [] }; 114 115 words.forEach(function (w) { 116 if (hasInternalCaps(w)) { 117 reasons.push({ id: 'internal_caps', detail: w, sample: w }); 118 return; 119 } 120 if (hasMixedScript(w)) { 121 reasons.push({ id: 'mixed_script', detail: w, sample: w }); 122 } 123 }); 124 125 var overlap = findPhraseOverlap(t); 126 if (overlap) reasons.push({ id: 'overlap', detail: overlap, sample: overlap }); 127 128 var merge = findWordMerge(words); 129 if (merge) reasons.push({ id: 'word_merge', detail: merge, sample: merge.split(' ')[0] }); 130 131 words.forEach(function (w) { 132 var clean = lettersOnly(w); 133 if (clean.length > 40 && !/^https?:/i.test(w)) { 134 reasons.push({ id: 'long_word', detail: w.slice(0, 24) + 'â¦', sample: w.slice(0, 12) }); 135 } 136 }); 137 138 words.forEach(function (w) { 139 var clean = lettersOnly(w).toLowerCase(); 140 if (clean.length >= 12 && vowelRatio(clean) < 0.14) { 141 reasons.push({ id: 'low_vowel', detail: w, sample: w }); 142 } 143 }); 144 145 var restart = findMidSentenceRestart(t); 146 if (restart) reasons.push({ id: 'mid_restart', detail: restart, sample: restart }); 147 148 var seen = {}; 149 var unique = []; 150 reasons.forEach(function (r) { 151 var key = r.id + '|' + r.detail; 152 if (seen[key]) return; 153 seen[key] = true; 154 unique.push(r); 155 }); 156 157 var score = 0; 158 unique.forEach(function (r) { 159 var meta = REASON_META[r.id]; 160 score += meta ? meta.score : 1; 161 }); 162 163 var suspicious = score >= 3; 164 return { suspicious: suspicious, score: score, reasons: unique }; 165 } 166 167 function reasonLabel(id, lang) { 168 var meta = REASON_META[id]; 169 if (!meta) return id; 170 return lang === 'bg' ? meta.bg : meta.en; 171 } 172 173 window.GriGsiSpaceComposeSanity = { 174 analyze: analyze, 175 reasonLabel: reasonLabel, 176 }; 177})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.