PageSourceSearch

https://grigsi.com/sendi/sendi-compose-sanity.js

js grigsi.com collected 2026-09-24 14:46:18 UTC 6,965 bytes, 177 lines download raw bytes

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.