@askeleven/sms-guard 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +267 -0
- package/bin/cli.js +212 -0
- package/package.json +59 -0
- package/src/data/countries.js +78 -0
- package/src/data/gsm.js +53 -0
- package/src/data/nanp.js +271 -0
- package/src/data/shorteners.js +10 -0
- package/src/index.js +420 -0
- package/src/mcp.js +289 -0
- package/src/phone.js +213 -0
- package/src/report.js +165 -0
- package/src/segments.js +139 -0
- package/src/split.js +127 -0
- package/src/version.js +6 -0
- package/types/data/countries.d.ts +2 -0
- package/types/data/gsm.d.ts +17 -0
- package/types/data/nanp.d.ts +17 -0
- package/types/data/shorteners.d.ts +5 -0
- package/types/index.d.ts +240 -0
- package/types/mcp.d.ts +23 -0
- package/types/phone.d.ts +121 -0
- package/types/report.d.ts +22 -0
- package/types/segments.d.ts +115 -0
- package/types/split.d.ts +46 -0
- package/types/version.d.ts +2 -0
package/src/phone.js
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { NANP_AREA_CODES } from './data/nanp.js'
|
|
2
|
+
import { COUNTRY_CODES } from './data/countries.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @typedef {object} ParsedNumber
|
|
6
|
+
* @property {string} e164 The number as +<digits>.
|
|
7
|
+
* @property {string} countryCode '1' for NANP, otherwise as matched in the country
|
|
8
|
+
* table, or an empty string when no row matches.
|
|
9
|
+
* @property {boolean} assumedNanp True when the number had no + and was read as a
|
|
10
|
+
* US or Canadian number because it had 10 digits (or 11 starting with 1).
|
|
11
|
+
*
|
|
12
|
+
* @typedef {object} ZoneLookup
|
|
13
|
+
* @property {string | null} region State, province or country, when known.
|
|
14
|
+
* @property {string[]} zones IANA zones. None means unknown; more than one, ambiguous.
|
|
15
|
+
* @property {string} basis What the zones were worked out from, in plain English.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** Formatting people type around a phone number, stripped before parsing. */
|
|
19
|
+
const FORMATTING = /[\s().\-/\u00A0]/g
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Reads a phone number. Accepts E.164 with or without formatting (+1 (305) 555-0100,
|
|
23
|
+
* +44 20 7946 0000). Without a leading +, only a 10-digit number, or an 11-digit
|
|
24
|
+
* number starting with 1, is accepted, and it is read as NANP (US, Canada and the
|
|
25
|
+
* rest of +1). Anything else without a + is refused rather than guessed at.
|
|
26
|
+
*
|
|
27
|
+
* @param {string} raw
|
|
28
|
+
* @returns {{ ok: true, number: ParsedNumber } | { ok: false, error: string }}
|
|
29
|
+
*/
|
|
30
|
+
export function parseNumber(raw) {
|
|
31
|
+
const input = String(raw).trim()
|
|
32
|
+
const hasPlus = input.startsWith('+')
|
|
33
|
+
const rest = (hasPlus ? input.slice(1) : input).replace(FORMATTING, '')
|
|
34
|
+
if (!/^\d+$/.test(rest)) {
|
|
35
|
+
return { ok: false, error: `${input || 'An empty string'} is not a phone number.` }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
let digits = rest
|
|
39
|
+
let assumedNanp = false
|
|
40
|
+
if (!hasPlus) {
|
|
41
|
+
if (digits.length === 10) {
|
|
42
|
+
digits = `1${digits}`
|
|
43
|
+
assumedNanp = true
|
|
44
|
+
} else if (digits.length === 11 && digits.startsWith('1')) {
|
|
45
|
+
assumedNanp = true
|
|
46
|
+
} else {
|
|
47
|
+
return {
|
|
48
|
+
ok: false,
|
|
49
|
+
error:
|
|
50
|
+
`${input} has no country code. Write it in E.164, starting with + and the ` +
|
|
51
|
+
'country code, such as +44 20 7946 0000.',
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (digits.length < 7 || digits.length > 15) {
|
|
57
|
+
return { ok: false, error: `${input} is not a valid E.164 number (7 to 15 digits).` }
|
|
58
|
+
}
|
|
59
|
+
if (digits.startsWith('1')) {
|
|
60
|
+
if (!/^1[2-9]\d{2}[2-9]\d{6}$/.test(digits)) {
|
|
61
|
+
return {
|
|
62
|
+
ok: false,
|
|
63
|
+
error: `${input} is not a valid US or Canadian number: +1, then 10 digits.`,
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return { ok: true, number: { e164: `+${digits}`, countryCode: '1', assumedNanp } }
|
|
67
|
+
}
|
|
68
|
+
const countryCode =
|
|
69
|
+
[3, 2, 1].map((n) => digits.slice(0, n)).find((prefix) => COUNTRY_CODES.has(prefix)) ?? null
|
|
70
|
+
return {
|
|
71
|
+
ok: true,
|
|
72
|
+
number: { e164: `+${digits}`, countryCode: countryCode ?? '', assumedNanp },
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The time zone, or zones, a number could be in.
|
|
78
|
+
*
|
|
79
|
+
* @param {ParsedNumber} number
|
|
80
|
+
* @returns {ZoneLookup}
|
|
81
|
+
*/
|
|
82
|
+
export function zonesFor(number) {
|
|
83
|
+
if (number.countryCode === '1') {
|
|
84
|
+
const areaCode = number.e164.slice(2, 5)
|
|
85
|
+
const entry = NANP_AREA_CODES.get(areaCode)
|
|
86
|
+
if (!entry) {
|
|
87
|
+
return {
|
|
88
|
+
region: null,
|
|
89
|
+
zones: [],
|
|
90
|
+
basis: `Area code ${areaCode} is not a geographic area code sms-guard knows.`,
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return { region: entry.region, zones: entry.zones, basis: `area code ${areaCode}` }
|
|
94
|
+
}
|
|
95
|
+
const entry = number.countryCode ? COUNTRY_CODES.get(number.countryCode) : undefined
|
|
96
|
+
if (!entry) {
|
|
97
|
+
return {
|
|
98
|
+
region: null,
|
|
99
|
+
zones: [],
|
|
100
|
+
basis: `The country for ${number.e164} is not in sms-guard's table.`,
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return { region: entry.region, zones: entry.zones, basis: `country code +${number.countryCode}` }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* @typedef {object} Window
|
|
108
|
+
* @property {number} start Minutes after local midnight, inclusive.
|
|
109
|
+
* @property {number} end Minutes after local midnight, exclusive.
|
|
110
|
+
* @property {string} label As given, HH:MM-HH:MM.
|
|
111
|
+
*/
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Parses HH:MM-HH:MM. The start is inclusive and the end exclusive, so 08:00-21:00
|
|
115
|
+
* allows 08:00 and 20:59 but not 21:00. A window that wraps midnight (22:00-06:00)
|
|
116
|
+
* is allowed.
|
|
117
|
+
*
|
|
118
|
+
* @param {string} window
|
|
119
|
+
* @returns {Window | null}
|
|
120
|
+
*/
|
|
121
|
+
export function parseWindow(window) {
|
|
122
|
+
const m = /^(\d{1,2}):(\d{2})-(\d{1,2}):(\d{2})$/.exec(String(window).trim())
|
|
123
|
+
if (!m) return null
|
|
124
|
+
const [sh, sm, eh, em] = m.slice(1).map(Number)
|
|
125
|
+
if (sm > 59 || em > 59 || sh > 23 || eh > 24 || (eh === 24 && em > 0)) return null
|
|
126
|
+
const start = sh * 60 + sm
|
|
127
|
+
const end = eh * 60 + em
|
|
128
|
+
if (start === end) return null
|
|
129
|
+
const pad = (/** @type {number} */ n) => String(n).padStart(2, '0')
|
|
130
|
+
return { start, end, label: `${pad(sh)}:${pad(sm)}-${pad(eh)}:${pad(em)}` }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* @param {number} minutes Minutes after local midnight.
|
|
135
|
+
* @param {Window} window
|
|
136
|
+
* @returns {boolean}
|
|
137
|
+
*/
|
|
138
|
+
export function inWindow(minutes, window) {
|
|
139
|
+
return window.start < window.end
|
|
140
|
+
? minutes >= window.start && minutes < window.end
|
|
141
|
+
: minutes >= window.start || minutes < window.end
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* @typedef {object} LocalTime
|
|
146
|
+
* @property {string} timezone
|
|
147
|
+
* @property {string} time Local wall-clock time, YYYY-MM-DDTHH:MM.
|
|
148
|
+
* @property {string} label Such as "Sun 19:30 EDT".
|
|
149
|
+
* @property {number} minutes Minutes after local midnight.
|
|
150
|
+
*/
|
|
151
|
+
|
|
152
|
+
/** @type {Map<string, Intl.DateTimeFormat>} */
|
|
153
|
+
const formatters = new Map()
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* The wall-clock time in a zone, from the runtime's own time zone data.
|
|
157
|
+
*
|
|
158
|
+
* @param {Date} date
|
|
159
|
+
* @param {string} timezone
|
|
160
|
+
* @returns {LocalTime}
|
|
161
|
+
*/
|
|
162
|
+
export function localTime(date, timezone) {
|
|
163
|
+
let format = formatters.get(timezone)
|
|
164
|
+
if (!format) {
|
|
165
|
+
format = new Intl.DateTimeFormat('en-US', {
|
|
166
|
+
timeZone: timezone,
|
|
167
|
+
year: 'numeric',
|
|
168
|
+
month: '2-digit',
|
|
169
|
+
day: '2-digit',
|
|
170
|
+
hour: '2-digit',
|
|
171
|
+
minute: '2-digit',
|
|
172
|
+
hourCycle: 'h23',
|
|
173
|
+
weekday: 'short',
|
|
174
|
+
timeZoneName: 'short',
|
|
175
|
+
})
|
|
176
|
+
formatters.set(timezone, format)
|
|
177
|
+
}
|
|
178
|
+
/** @type {Record<string, string>} */
|
|
179
|
+
const p = {}
|
|
180
|
+
for (const part of format.formatToParts(date)) p[part.type] = part.value
|
|
181
|
+
const hour = p.hour === '24' ? '00' : p.hour
|
|
182
|
+
return {
|
|
183
|
+
timezone,
|
|
184
|
+
time: `${p.year}-${p.month}-${p.day}T${hour}:${p.minute}`,
|
|
185
|
+
label: `${p.weekday} ${hour}:${p.minute} ${p.timeZoneName}`,
|
|
186
|
+
minutes: Number(hour) * 60 + Number(p.minute),
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* The first minute at or after `from` when it is inside the window in every one of
|
|
192
|
+
* the zones, or null if there is none in the next two days.
|
|
193
|
+
*
|
|
194
|
+
* @param {Date} from
|
|
195
|
+
* @param {string[]} zones
|
|
196
|
+
* @param {Window} window
|
|
197
|
+
* @returns {Date | null}
|
|
198
|
+
*/
|
|
199
|
+
export function nextWindowStart(from, zones, window) {
|
|
200
|
+
const MINUTE = 60_000
|
|
201
|
+
const ok = (/** @type {number} */ t) =>
|
|
202
|
+
zones.every((z) => inWindow(localTime(new Date(t), z).minutes, window))
|
|
203
|
+
const start = Math.ceil(from.getTime() / MINUTE) * MINUTE
|
|
204
|
+
const limit = start + 2 * 24 * 60 * MINUTE
|
|
205
|
+
// Coarse steps to find the opening, then minute by minute back from it.
|
|
206
|
+
for (let t = start; t <= limit; t += 15 * MINUTE) {
|
|
207
|
+
if (!ok(t)) continue
|
|
208
|
+
let first = t
|
|
209
|
+
while (first - MINUTE >= start && ok(first - MINUTE)) first -= MINUTE
|
|
210
|
+
return new Date(first)
|
|
211
|
+
}
|
|
212
|
+
return null
|
|
213
|
+
}
|
package/src/report.js
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @typedef {import('./index.js').Result} Result
|
|
3
|
+
* @typedef {import('./index.js').ReasonCode} ReasonCode
|
|
4
|
+
* @typedef {import('./index.js').Verdict} Verdict
|
|
5
|
+
* @typedef {import('./split.js').Part} Part
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const COLOURS = {
|
|
9
|
+
reset: '\x1b[0m',
|
|
10
|
+
dim: '\x1b[2m',
|
|
11
|
+
bold: '\x1b[1m',
|
|
12
|
+
red: '\x1b[31m',
|
|
13
|
+
yellow: '\x1b[33m',
|
|
14
|
+
green: '\x1b[32m',
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Headings for each reason. */
|
|
18
|
+
const REASON_TITLES = /** @type {Record<ReasonCode, string>} */ ({
|
|
19
|
+
empty: 'Empty message',
|
|
20
|
+
too_many_segments: 'Too many segments',
|
|
21
|
+
invalid_number: 'Not a valid phone number',
|
|
22
|
+
quiet_hours: 'Outside allowed hours',
|
|
23
|
+
timezone_ambiguous: 'Time zone ambiguous',
|
|
24
|
+
timezone_unknown: 'Time zone unknown',
|
|
25
|
+
url_shortener: 'Public link shortener',
|
|
26
|
+
no_opt_out: 'No opt-out wording',
|
|
27
|
+
ucs2: 'Sent as UCS-2',
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
const HEADLINES = /** @type {Record<Verdict, string>} */ ({
|
|
31
|
+
keep: 'OK to send',
|
|
32
|
+
review: 'A person should look at this before it is sent',
|
|
33
|
+
fail: 'Do not send this as it stands',
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @param {boolean} enabled
|
|
38
|
+
* @returns {(code: string, text: string) => string}
|
|
39
|
+
*/
|
|
40
|
+
function painter(enabled) {
|
|
41
|
+
return (code, text) => (enabled ? `${code}${text}${COLOURS.reset}` : text)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @param {string} text
|
|
46
|
+
* @param {number} width
|
|
47
|
+
* @param {string} indent
|
|
48
|
+
* @returns {string}
|
|
49
|
+
*/
|
|
50
|
+
function wrap(text, width, indent) {
|
|
51
|
+
/** @type {string[]} */
|
|
52
|
+
const lines = []
|
|
53
|
+
let line = ''
|
|
54
|
+
for (const word of text.split(/\s+/)) {
|
|
55
|
+
if (line && line.length + word.length + 1 > width) {
|
|
56
|
+
lines.push(line)
|
|
57
|
+
line = word
|
|
58
|
+
} else {
|
|
59
|
+
line = line ? `${line} ${word}` : word
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (line) lines.push(line)
|
|
63
|
+
return lines.map((l) => indent + l).join('\n')
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* @param {Result} result
|
|
68
|
+
* @param {{ colour?: boolean, width?: number }} [options]
|
|
69
|
+
* @returns {string}
|
|
70
|
+
*/
|
|
71
|
+
export function formatText(result, options = {}) {
|
|
72
|
+
const { colour = true, width = 76 } = options
|
|
73
|
+
const paint = painter(colour)
|
|
74
|
+
const { facts } = result
|
|
75
|
+
const lines = ['']
|
|
76
|
+
|
|
77
|
+
const label = { keep: 'PASS', review: 'REVIEW', fail: 'FAIL' }[result.verdict]
|
|
78
|
+
const tint = { keep: COLOURS.green, review: COLOURS.yellow, fail: COLOURS.red }[result.verdict]
|
|
79
|
+
lines.push(`${paint(tint, label)} ${paint(COLOURS.bold, HEADLINES[result.verdict])}`)
|
|
80
|
+
lines.push('')
|
|
81
|
+
|
|
82
|
+
const row = (/** @type {string} */ name, /** @type {string} */ value) =>
|
|
83
|
+
lines.push(` ${paint(COLOURS.dim, name.padEnd(12))} ${value}`)
|
|
84
|
+
row('Encoding', facts.encoding)
|
|
85
|
+
row(
|
|
86
|
+
'Length',
|
|
87
|
+
`${facts.characters} character${facts.characters === 1 ? '' : 's'}, ${facts.units} ` +
|
|
88
|
+
(facts.encoding === 'GSM-7' ? 'septets' : 'UTF-16 units'),
|
|
89
|
+
)
|
|
90
|
+
const over = facts.segments > facts.maxSegments
|
|
91
|
+
row(
|
|
92
|
+
'Segments',
|
|
93
|
+
`${over ? paint(COLOURS.red, String(facts.segments)) : facts.segments} of up to ${facts.perSegment} ` +
|
|
94
|
+
`units each (limit ${facts.maxSegments})`,
|
|
95
|
+
)
|
|
96
|
+
const r = facts.recipient
|
|
97
|
+
if (r) {
|
|
98
|
+
const where = [r.number ?? r.input, r.region, r.timezone].filter(Boolean).join(', ')
|
|
99
|
+
row('Recipient', where)
|
|
100
|
+
if (r.local.length > 0) {
|
|
101
|
+
for (const [i, t] of r.local.entries()) {
|
|
102
|
+
const mark = t.inWindow ? 'inside' : paint(COLOURS.red, 'outside')
|
|
103
|
+
const zone = r.local.length > 1 ? ` in ${t.timezone}` : ''
|
|
104
|
+
row(i === 0 ? 'Local time' : '', `${t.label}${zone}, ${mark} ${facts.window}`)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
} else {
|
|
108
|
+
row('Recipient', paint(COLOURS.dim, 'not given, send time not checked'))
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
for (const verdict of /** @type {const} */ (['fail', 'review', 'keep'])) {
|
|
112
|
+
const reasons = result.reasons.filter((x) => x.verdict === verdict)
|
|
113
|
+
if (reasons.length === 0) continue
|
|
114
|
+
lines.push('')
|
|
115
|
+
lines.push(
|
|
116
|
+
verdict === 'fail'
|
|
117
|
+
? paint(COLOURS.red, 'FAIL')
|
|
118
|
+
: verdict === 'review'
|
|
119
|
+
? paint(COLOURS.yellow, 'REVIEW')
|
|
120
|
+
: paint(COLOURS.dim, 'NOTE'),
|
|
121
|
+
)
|
|
122
|
+
for (const reason of reasons) {
|
|
123
|
+
lines.push(` ${paint(COLOURS.bold, REASON_TITLES[reason.code])}`)
|
|
124
|
+
lines.push(wrap(reason.detail, width - 6, ' '))
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (result.notes.length) {
|
|
129
|
+
lines.push('')
|
|
130
|
+
for (const note of result.notes) lines.push(paint(COLOURS.dim, wrap(note, width - 2, ' ')))
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
lines.push('')
|
|
134
|
+
return lines.join('\n')
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* @param {Part[]} parts
|
|
139
|
+
* @param {{ maxSegments: number, colour?: boolean }} options
|
|
140
|
+
* @returns {string}
|
|
141
|
+
*/
|
|
142
|
+
export function formatSplit(parts, options) {
|
|
143
|
+
const { maxSegments, colour = true } = options
|
|
144
|
+
const paint = painter(colour)
|
|
145
|
+
const lines = ['']
|
|
146
|
+
const n = parts.length
|
|
147
|
+
lines.push(
|
|
148
|
+
`${paint(COLOURS.green, 'PASS')} ${paint(COLOURS.bold, `${n} message${n === 1 ? '' : 's'}`)}` +
|
|
149
|
+
paint(COLOURS.dim, `, each within ${maxSegments} segment${maxSegments === 1 ? '' : 's'}. Send them in order.`),
|
|
150
|
+
)
|
|
151
|
+
for (const [i, part] of parts.entries()) {
|
|
152
|
+
lines.push('')
|
|
153
|
+
lines.push(
|
|
154
|
+
` ${paint(COLOURS.bold, `${i + 1}/${n}`)} ` +
|
|
155
|
+
paint(
|
|
156
|
+
COLOURS.dim,
|
|
157
|
+
`${part.segments} segment${part.segments === 1 ? '' : 's'}, ${part.encoding}, ` +
|
|
158
|
+
`${part.characters} characters`,
|
|
159
|
+
),
|
|
160
|
+
)
|
|
161
|
+
for (const line of part.text.split('\n')) lines.push(` ${line}`)
|
|
162
|
+
}
|
|
163
|
+
lines.push('')
|
|
164
|
+
return lines.join('\n')
|
|
165
|
+
}
|
package/src/segments.js
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { GSM_BASIC, GSM_EXTENSION, GSM_REPLACEMENTS } from './data/gsm.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @typedef {'GSM-7'|'UCS-2'} Encoding
|
|
5
|
+
*
|
|
6
|
+
* @typedef {object} SegmentCount
|
|
7
|
+
* @property {Encoding} encoding
|
|
8
|
+
* @property {number} characters Unicode code points, what a person would count.
|
|
9
|
+
* @property {number} units What the network counts: septets for GSM-7 (an extension
|
|
10
|
+
* character is two), UTF-16 code units for UCS-2 (an emoji is usually two).
|
|
11
|
+
* @property {number} segments Parts the message is sent as. 0 for an empty message.
|
|
12
|
+
* @property {number} perSegment Units that fit in each part: 160 or 70 alone, 153 or
|
|
13
|
+
* 67 once a message is split, since the rest of each part carries the header that
|
|
14
|
+
* joins them back together.
|
|
15
|
+
*
|
|
16
|
+
* @typedef {object} Ucs2Character
|
|
17
|
+
* @property {string} char
|
|
18
|
+
* @property {string} codePoint As U+XXXX.
|
|
19
|
+
* @property {number} count Times it appears.
|
|
20
|
+
* @property {string | null} replacement A GSM-7 equivalent, offered only for
|
|
21
|
+
* typographic characters such as curly quotes, dashes and odd spaces.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** Units per message: [single, per part once concatenated]. */
|
|
25
|
+
export const LIMITS = {
|
|
26
|
+
'GSM-7': { single: 160, part: 153 },
|
|
27
|
+
'UCS-2': { single: 70, part: 67 },
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* GSM-7 when every character is in the default alphabet or its extension table,
|
|
32
|
+
* UCS-2 otherwise. One character outside both is enough to switch the whole message.
|
|
33
|
+
*
|
|
34
|
+
* @param {string} text
|
|
35
|
+
* @returns {Encoding}
|
|
36
|
+
*/
|
|
37
|
+
export function encodingOf(text) {
|
|
38
|
+
for (const char of text) {
|
|
39
|
+
if (!GSM_BASIC.has(char) && !GSM_EXTENSION.has(char)) return 'UCS-2'
|
|
40
|
+
}
|
|
41
|
+
return 'GSM-7'
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* What one character costs in the given encoding.
|
|
46
|
+
*
|
|
47
|
+
* @param {string} char One code point.
|
|
48
|
+
* @param {Encoding} encoding
|
|
49
|
+
* @returns {number}
|
|
50
|
+
*/
|
|
51
|
+
export function unitCost(char, encoding) {
|
|
52
|
+
if (encoding === 'GSM-7') return GSM_EXTENSION.has(char) ? 2 : 1
|
|
53
|
+
return char.length // 2 for anything outside the Basic Multilingual Plane
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Counts segments the way the network does. A GSM-7 escape pair, or a UTF-16
|
|
58
|
+
* surrogate pair, is never cut across two parts: when it does not fit in what is left
|
|
59
|
+
* of a part, the part is sent short and the pair starts the next one.
|
|
60
|
+
*
|
|
61
|
+
* @param {string} text
|
|
62
|
+
* @returns {SegmentCount}
|
|
63
|
+
*/
|
|
64
|
+
export function countSegments(text) {
|
|
65
|
+
const encoding = encodingOf(text)
|
|
66
|
+
const { single, part } = LIMITS[encoding]
|
|
67
|
+
let characters = 0
|
|
68
|
+
let units = 0
|
|
69
|
+
/** @type {number[]} */
|
|
70
|
+
const costs = []
|
|
71
|
+
for (const char of text) {
|
|
72
|
+
const cost = unitCost(char, encoding)
|
|
73
|
+
characters++
|
|
74
|
+
units += cost
|
|
75
|
+
costs.push(cost)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (units === 0) return { encoding, characters, units, segments: 0, perSegment: single }
|
|
79
|
+
if (units <= single) return { encoding, characters, units, segments: 1, perSegment: single }
|
|
80
|
+
|
|
81
|
+
let segments = 1
|
|
82
|
+
let used = 0
|
|
83
|
+
for (const cost of costs) {
|
|
84
|
+
if (used + cost > part) {
|
|
85
|
+
segments++
|
|
86
|
+
used = cost
|
|
87
|
+
} else {
|
|
88
|
+
used += cost
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return { encoding, characters, units, segments, perSegment: part }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The characters that force a message into UCS-2, each once, in order of first
|
|
96
|
+
* appearance, with a GSM-7 replacement where one reads the same.
|
|
97
|
+
*
|
|
98
|
+
* @param {string} text
|
|
99
|
+
* @returns {Ucs2Character[]}
|
|
100
|
+
*/
|
|
101
|
+
export function ucs2Characters(text) {
|
|
102
|
+
/** @type {Map<string, Ucs2Character>} */
|
|
103
|
+
const found = new Map()
|
|
104
|
+
for (const char of text) {
|
|
105
|
+
if (GSM_BASIC.has(char) || GSM_EXTENSION.has(char)) continue
|
|
106
|
+
const entry = found.get(char)
|
|
107
|
+
if (entry) {
|
|
108
|
+
entry.count++
|
|
109
|
+
} else {
|
|
110
|
+
found.set(char, {
|
|
111
|
+
char,
|
|
112
|
+
codePoint: codePointOf(char),
|
|
113
|
+
count: 1,
|
|
114
|
+
replacement: GSM_REPLACEMENTS.get(char) ?? null,
|
|
115
|
+
})
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return [...found.values()]
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Applies the typographic replacements, and nothing else.
|
|
123
|
+
*
|
|
124
|
+
* @param {string} text
|
|
125
|
+
* @returns {string}
|
|
126
|
+
*/
|
|
127
|
+
export function replaceTypographic(text) {
|
|
128
|
+
let out = ''
|
|
129
|
+
for (const char of text) out += GSM_REPLACEMENTS.get(char) ?? char
|
|
130
|
+
return out
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* @param {string} char
|
|
135
|
+
* @returns {string}
|
|
136
|
+
*/
|
|
137
|
+
export function codePointOf(char) {
|
|
138
|
+
return `U+${(char.codePointAt(0) ?? 0).toString(16).toUpperCase().padStart(4, '0')}`
|
|
139
|
+
}
|
package/src/split.js
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { countSegments } from './segments.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @typedef {import('./segments.js').Encoding} Encoding
|
|
5
|
+
*
|
|
6
|
+
* @typedef {object} Part
|
|
7
|
+
* @property {string} text
|
|
8
|
+
* @property {Encoding} encoding Worked out for this part alone: a part with no
|
|
9
|
+
* accented letters can go back to GSM-7.
|
|
10
|
+
* @property {number} characters
|
|
11
|
+
* @property {number} units
|
|
12
|
+
* @property {number} segments
|
|
13
|
+
*
|
|
14
|
+
* @typedef {object} SplitOptions
|
|
15
|
+
* @property {number} [maxSegments] Most segments any one part may use. Default 5.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export const DEFAULT_MAX_SEGMENTS = 5
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Where a message may be broken, from the most natural to the last resort, and what
|
|
22
|
+
* goes back between two pieces when they are packed into the same part.
|
|
23
|
+
*
|
|
24
|
+
* @type {Array<{ split: (text: string) => string[], join: string }>}
|
|
25
|
+
*/
|
|
26
|
+
const LEVELS = [
|
|
27
|
+
{ split: (t) => t.split(/\n[ \t]*\n\s*/), join: '\n\n' }, // paragraphs
|
|
28
|
+
{ split: (t) => t.split(/\n/), join: '\n' }, // lines
|
|
29
|
+
{ split: (t) => t.split(/(?<=[.!?\u2026])\s+/), join: ' ' }, // sentences
|
|
30
|
+
{ split: (t) => t.split(/\s+/), join: ' ' }, // words
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Splits a message into parts that each fit within maxSegments, to be sent in order
|
|
35
|
+
* as separate texts. Breaks at a blank line where it can, then a line, then a
|
|
36
|
+
* sentence, then a word, and only cuts inside a word when a single word is too long.
|
|
37
|
+
* Parts are packed greedily, so there are as few as the breaks allow. Whitespace at
|
|
38
|
+
* each break is dropped. A GSM-7 escape pair or a surrogate pair is never cut, since
|
|
39
|
+
* the text is only ever cut between whole characters.
|
|
40
|
+
*
|
|
41
|
+
* @param {string} text
|
|
42
|
+
* @param {SplitOptions} [options]
|
|
43
|
+
* @returns {Part[]} Empty for a message with nothing but whitespace.
|
|
44
|
+
*/
|
|
45
|
+
export function splitMessage(text, options = {}) {
|
|
46
|
+
const { maxSegments = DEFAULT_MAX_SEGMENTS } = options
|
|
47
|
+
if (!Number.isInteger(maxSegments) || maxSegments < 1) {
|
|
48
|
+
throw new RangeError(`maxSegments must be a whole number of 1 or more, got ${maxSegments}`)
|
|
49
|
+
}
|
|
50
|
+
const fits = (/** @type {string} */ t) => countSegments(t).segments <= maxSegments
|
|
51
|
+
const body = String(text).trim()
|
|
52
|
+
if (!body) return []
|
|
53
|
+
return pack(body, 0, fits).map(describe)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* @param {string} text Trimmed, not empty.
|
|
58
|
+
* @param {number} level
|
|
59
|
+
* @param {(text: string) => boolean} fits
|
|
60
|
+
* @returns {string[]}
|
|
61
|
+
*/
|
|
62
|
+
function pack(text, level, fits) {
|
|
63
|
+
if (fits(text)) return [text]
|
|
64
|
+
if (level >= LEVELS.length) return hardSplit(text, fits)
|
|
65
|
+
|
|
66
|
+
const { split, join } = LEVELS[level]
|
|
67
|
+
const pieces = split(text)
|
|
68
|
+
.map((p) => p.trim())
|
|
69
|
+
.filter(Boolean)
|
|
70
|
+
if (pieces.length <= 1) return pack(text, level + 1, fits)
|
|
71
|
+
|
|
72
|
+
/** @type {string[]} */
|
|
73
|
+
const out = []
|
|
74
|
+
/** @type {string | null} */
|
|
75
|
+
let current = null
|
|
76
|
+
for (const piece of pieces) {
|
|
77
|
+
if (!fits(piece)) {
|
|
78
|
+
if (current !== null) out.push(current)
|
|
79
|
+
const sub = pack(piece, level + 1, fits)
|
|
80
|
+
out.push(...sub.slice(0, -1))
|
|
81
|
+
current = sub[sub.length - 1]
|
|
82
|
+
continue
|
|
83
|
+
}
|
|
84
|
+
if (current === null) {
|
|
85
|
+
current = piece
|
|
86
|
+
} else if (fits(current + join + piece)) {
|
|
87
|
+
current = current + join + piece
|
|
88
|
+
} else {
|
|
89
|
+
out.push(current)
|
|
90
|
+
current = piece
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (current !== null) out.push(current)
|
|
94
|
+
return out
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Cuts between characters, filling each part as far as it goes.
|
|
99
|
+
*
|
|
100
|
+
* @param {string} text
|
|
101
|
+
* @param {(text: string) => boolean} fits
|
|
102
|
+
* @returns {string[]}
|
|
103
|
+
*/
|
|
104
|
+
function hardSplit(text, fits) {
|
|
105
|
+
/** @type {string[]} */
|
|
106
|
+
const out = []
|
|
107
|
+
let current = ''
|
|
108
|
+
for (const char of text) {
|
|
109
|
+
if (current && !fits(current + char)) {
|
|
110
|
+
out.push(current)
|
|
111
|
+
current = char
|
|
112
|
+
} else {
|
|
113
|
+
current += char
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
if (current) out.push(current)
|
|
117
|
+
return out.map((p) => p.trim()).filter(Boolean)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* @param {string} text
|
|
122
|
+
* @returns {Part}
|
|
123
|
+
*/
|
|
124
|
+
function describe(text) {
|
|
125
|
+
const { encoding, characters, units, segments } = countSegments(text)
|
|
126
|
+
return { text, encoding, characters, units, segments }
|
|
127
|
+
}
|
package/src/version.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
|
|
3
|
+
/** The published version, read from package.json so there is one place to bump it. */
|
|
4
|
+
export const VERSION = /** @type {{ version: string }} */ (
|
|
5
|
+
JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'))
|
|
6
|
+
).version
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The GSM 03.38 default alphabet (3GPP TS 23.038), in code order, without the escape
|
|
3
|
+
* at 0x1B. Non-ASCII characters are written as escapes so no lookalike can creep in.
|
|
4
|
+
* Note that it is not ASCII: 0x24 is the currency sign, and the backtick is missing.
|
|
5
|
+
*/
|
|
6
|
+
export const GSM_BASIC: Set<string>;
|
|
7
|
+
/**
|
|
8
|
+
* The extension table: each is sent as the escape plus one septet, so it costs two.
|
|
9
|
+
*/
|
|
10
|
+
export const GSM_EXTENSION: Set<string>;
|
|
11
|
+
/**
|
|
12
|
+
* Typographic characters with a GSM-7 equivalent that reads the same. Only these are
|
|
13
|
+
* ever offered as replacements. Accented letters are real text and are left alone.
|
|
14
|
+
*
|
|
15
|
+
* @type {Map<string, string>}
|
|
16
|
+
*/
|
|
17
|
+
export const GSM_REPLACEMENTS: Map<string, string>;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @typedef {object} ZoneEntry
|
|
3
|
+
* @property {string} region State, province, territory or country.
|
|
4
|
+
* @property {string[]} zones IANA zones. More than one means ambiguous.
|
|
5
|
+
*/
|
|
6
|
+
/** @type {Map<string, ZoneEntry>} */
|
|
7
|
+
export const NANP_AREA_CODES: Map<string, ZoneEntry>;
|
|
8
|
+
export type ZoneEntry = {
|
|
9
|
+
/**
|
|
10
|
+
* State, province, territory or country.
|
|
11
|
+
*/
|
|
12
|
+
region: string;
|
|
13
|
+
/**
|
|
14
|
+
* IANA zones. More than one means ambiguous.
|
|
15
|
+
*/
|
|
16
|
+
zones: string[];
|
|
17
|
+
};
|