tdk-api-wrapper 1.5.1 → 1.6.1
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/README.md +1 -1
- package/dist/{chunk-NEC5MIQM.mjs → chunk-ZFWZXNGY.mjs} +81 -4
- package/dist/cli.js +81 -4
- package/dist/cli.mjs +1 -1
- package/dist/index.d.mts +10 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +81 -4
- package/dist/index.mjs +1 -1
- package/package.json +23 -3
- package/.github/workflows/publish.yml +0 -41
- package/src/cli.ts +0 -535
- package/src/errors.ts +0 -38
- package/src/index.ts +0 -4
- package/src/morphology.ts +0 -333
- package/src/tdk.ts +0 -1962
- package/src/types.ts +0 -185
- package/test/grammar.test.js +0 -52
- package/test/morphology.test.js +0 -129
- package/test/proofread.test.js +0 -88
- package/test/tools.test.js +0 -51
- package/tsconfig.json +0 -11
package/src/tdk.ts
DELETED
|
@@ -1,1962 +0,0 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
WordInfo,
|
|
3
|
-
DailyContent,
|
|
4
|
-
SpellCheckResult,
|
|
5
|
-
StemResult,
|
|
6
|
-
WordOfTheDay,
|
|
7
|
-
DailyPick,
|
|
8
|
-
WordComparison,
|
|
9
|
-
WordAnalysis,
|
|
10
|
-
TDKRule,
|
|
11
|
-
KubbealtiEntry,
|
|
12
|
-
WiktionaryEntry,
|
|
13
|
-
ProofreadIssue,
|
|
14
|
-
ProofreadResult,
|
|
15
|
-
PatternSearchOptions,
|
|
16
|
-
AnagramOptions,
|
|
17
|
-
RhymeOptions,
|
|
18
|
-
TDKConfig,
|
|
19
|
-
} from "./types";
|
|
20
|
-
import { TDKValidationError, TDKNetworkError } from "./errors";
|
|
21
|
-
import { getStemCandidates } from "./morphology";
|
|
22
|
-
import * as fs from "node:fs";
|
|
23
|
-
import * as path from "node:path";
|
|
24
|
-
import * as os from "node:os";
|
|
25
|
-
import * as https from "node:https";
|
|
26
|
-
import * as tls from "node:tls";
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* Known frequent Turkish misspellings, erroneously joined compound words,
|
|
30
|
-
* and words where vowel dropping is prohibited by TDK (Yazım Kılavuzu).
|
|
31
|
-
*/
|
|
32
|
-
export const COMMON_MISSPELLINGS: Record<string, string> = {
|
|
33
|
-
// -şey ile biten ve ayrı yazılması zorunlu söz öbekleri
|
|
34
|
-
herşey: "her şey",
|
|
35
|
-
hersey: "her şey",
|
|
36
|
-
birşey: "bir şey",
|
|
37
|
-
birsey: "bir şey",
|
|
38
|
-
hiçbirşey: "hiçbir şey",
|
|
39
|
-
hicbirsey: "hiçbir şey",
|
|
40
|
-
çokşey: "çok şey",
|
|
41
|
-
coksey: "çok şey",
|
|
42
|
-
şeyler: "şeyler",
|
|
43
|
-
seyler: "şeyler",
|
|
44
|
-
herhangibirşey: "herhangi bir şey",
|
|
45
|
-
herhangibirsey: "herhangi bir şey",
|
|
46
|
-
|
|
47
|
-
// Sıkça birleşik yazılan ama ayrı yazılması gereken sözler
|
|
48
|
-
hergün: "her gün",
|
|
49
|
-
hergun: "her gün",
|
|
50
|
-
herzaman: "her zaman",
|
|
51
|
-
heran: "her an",
|
|
52
|
-
heryer: "her yer",
|
|
53
|
-
herbiri: "her biri",
|
|
54
|
-
pekçok: "pek çok",
|
|
55
|
-
pekcok: "pek çok",
|
|
56
|
-
pekaz: "pek az",
|
|
57
|
-
yada: "ya da",
|
|
58
|
-
tabiki: "tabii ki",
|
|
59
|
-
tabiiki: "tabii ki",
|
|
60
|
-
sağol: "sağ ol",
|
|
61
|
-
sagol: "sağ ol",
|
|
62
|
-
sağolun: "sağ olun",
|
|
63
|
-
sagolun: "sağ olun",
|
|
64
|
-
hoşçakal: "hoşça kal",
|
|
65
|
-
hoscakal: "hoşça kal",
|
|
66
|
-
hoşgeldin: "hoş geldin",
|
|
67
|
-
hosgeldin: "hoş geldin",
|
|
68
|
-
hoşgeldiniz: "hoş geldiniz",
|
|
69
|
-
hosgeldiniz: "hoş geldiniz",
|
|
70
|
-
hoşbulduk: "hoş bulduk",
|
|
71
|
-
hosbulduk: "hoş bulduk",
|
|
72
|
-
yanısıra: "yanı sıra",
|
|
73
|
-
yanisira: "yanı sıra",
|
|
74
|
-
peşisıra: "peşi sıra",
|
|
75
|
-
pesisira: "peşi sıra",
|
|
76
|
-
ardısıra: "ardı sıra",
|
|
77
|
-
ardisira: "ardı sıra",
|
|
78
|
-
artarda: "art arda",
|
|
79
|
-
yüzyüze: "yüz yüze",
|
|
80
|
-
yuzyuze: "yüz yüze",
|
|
81
|
-
elele: "el ele",
|
|
82
|
-
gözgöze: "göz göze",
|
|
83
|
-
başbaşa: "baş başa",
|
|
84
|
-
basbasa: "baş başa",
|
|
85
|
-
yanyana: "yan yana",
|
|
86
|
-
içiçe: "iç içe",
|
|
87
|
-
icice: "iç içe",
|
|
88
|
-
üstüste: "üst üste",
|
|
89
|
-
ustuste: "üst üste",
|
|
90
|
-
altalta: "alt alta",
|
|
91
|
-
önsöz: "ön söz",
|
|
92
|
-
onsoz: "ön söz",
|
|
93
|
-
önyargı: "ön yargı",
|
|
94
|
-
onyargi: "ön yargı",
|
|
95
|
-
farketmek: "fark etmek",
|
|
96
|
-
farketti: "fark etti",
|
|
97
|
-
farkettim: "fark ettim",
|
|
98
|
-
farkeder: "fark eder",
|
|
99
|
-
farketmez: "fark etmez",
|
|
100
|
-
terketmek: "terk etmek",
|
|
101
|
-
terketti: "terk etti",
|
|
102
|
-
ayırdetmek: "ayırt etmek",
|
|
103
|
-
ayırtetmek: "ayırt etmek",
|
|
104
|
-
arzetmek: "arz etmek",
|
|
105
|
-
arzederim: "arz ederim",
|
|
106
|
-
varolmak: "var olmak",
|
|
107
|
-
yokolmak: "yok olmak",
|
|
108
|
-
haketmek: "hak etmek",
|
|
109
|
-
haketti: "hak etti",
|
|
110
|
-
hakkaten: "hakikaten",
|
|
111
|
-
hiçkimse: "hiç kimse",
|
|
112
|
-
hickimse: "hiç kimse",
|
|
113
|
-
|
|
114
|
-
// Ünlü düşmesi yapılmaması gereken yer bildiren sözler (TDK Kural 15)
|
|
115
|
-
burda: "burada",
|
|
116
|
-
burdan: "buradan",
|
|
117
|
-
şurda: "şurada",
|
|
118
|
-
surda: "şurada",
|
|
119
|
-
şurdan: "şuradan",
|
|
120
|
-
surdan: "şuradan",
|
|
121
|
-
orda: "orada",
|
|
122
|
-
ordan: "oradan",
|
|
123
|
-
içerde: "içeride",
|
|
124
|
-
icerde: "içeride",
|
|
125
|
-
içerden: "içeriden",
|
|
126
|
-
icerden: "içeriden",
|
|
127
|
-
dışarda: "dışarıda",
|
|
128
|
-
disarda: "dışarıda",
|
|
129
|
-
dışardan: "dışarıdan",
|
|
130
|
-
disardan: "dışarıdan",
|
|
131
|
-
yukarda: "yukarıda",
|
|
132
|
-
yukardan: "yukarıdan",
|
|
133
|
-
|
|
134
|
-
// Sıkça yanlış yazılan sözcükler
|
|
135
|
-
herkez: "herkes",
|
|
136
|
-
yanlız: "yalnız",
|
|
137
|
-
yalnış: "yanlış",
|
|
138
|
-
orjinal: "orijinal",
|
|
139
|
-
labaratuar: "laboratuvar",
|
|
140
|
-
laboratuar: "laboratuvar",
|
|
141
|
-
şöför: "şoför",
|
|
142
|
-
sofor: "şoför",
|
|
143
|
-
egzos: "egzoz",
|
|
144
|
-
eksoz: "egzoz",
|
|
145
|
-
ekzoz: "egzoz",
|
|
146
|
-
kiprik: "kirpik",
|
|
147
|
-
kirbit: "kibrit",
|
|
148
|
-
klavuz: "kılavuz",
|
|
149
|
-
kıravat: "kravat",
|
|
150
|
-
süpriz: "sürpriz",
|
|
151
|
-
supriz: "sürpriz",
|
|
152
|
-
raslantı: "rastlantı",
|
|
153
|
-
hastahane: "hastane",
|
|
154
|
-
pastahane: "pastane",
|
|
155
|
-
postahane: "postane",
|
|
156
|
-
eczahane: "eczane",
|
|
157
|
-
meyva: "meyve",
|
|
158
|
-
sarmısak: "sarımsak",
|
|
159
|
-
dinazor: "dinozor",
|
|
160
|
-
pantalon: "pantolon",
|
|
161
|
-
tesbih: "tespih",
|
|
162
|
-
ahçı: "aşçı",
|
|
163
|
-
matba: "matbaa",
|
|
164
|
-
idda: "iddia",
|
|
165
|
-
iddaa: "iddia",
|
|
166
|
-
muhattap: "muhatap",
|
|
167
|
-
traş: "tıraş",
|
|
168
|
-
karnıbahar: "karnabahar",
|
|
169
|
-
kareografi: "koreografi",
|
|
170
|
-
poaça: "poğaça",
|
|
171
|
-
pohaça: "poğaça",
|
|
172
|
-
şarz: "şarj",
|
|
173
|
-
sarj: "şarj",
|
|
174
|
-
makina: "makine",
|
|
175
|
-
müsade: "müsaade",
|
|
176
|
-
entellektüel: "entelektüel",
|
|
177
|
-
inisiyatif: "inisiyatif",
|
|
178
|
-
insiyatif: "inisiyatif",
|
|
179
|
-
sezeryan: "sezaryen",
|
|
180
|
-
doküman: "doküman",
|
|
181
|
-
döküman: "doküman",
|
|
182
|
-
erozyon: "erozyon",
|
|
183
|
-
erizyon: "erozyon",
|
|
184
|
-
anane: "anneanne",
|
|
185
|
-
babaanne: "babaanne",
|
|
186
|
-
};
|
|
187
|
-
|
|
188
|
-
export const SEY_EXCEPTIONS = new Set(["düşey", "eşey", "konsey", "jersey", "şey"]);
|
|
189
|
-
|
|
190
|
-
/**
|
|
191
|
-
* TDK (Türk Dil Kurumu) API Wrapper
|
|
192
|
-
*/
|
|
193
|
-
export class TDK {
|
|
194
|
-
private static readonly BASE_URL = "https://sozluk.gov.tr";
|
|
195
|
-
private static readonly AUDIO_API_HOST = "api.sozluk.gov.tr";
|
|
196
|
-
private static readonly KUBBEALTI_HOST = "eski.lugatim.com";
|
|
197
|
-
|
|
198
|
-
/**
|
|
199
|
-
* `eski.lugatim.com` (Kubbealtı Lugatı's data API) sends only its leaf
|
|
200
|
-
* certificate during the TLS handshake, omitting the intermediates a
|
|
201
|
-
* correctly configured server would include — a server-side misconfiguration,
|
|
202
|
-
* not something we should paper over by disabling verification. These are
|
|
203
|
-
* the two certificates the server *should* be sending (fetched from the
|
|
204
|
-
* leaf's own Authority Information Access URLs), supplied here so Node can
|
|
205
|
-
* still build a full, properly verified chain up to a root it already
|
|
206
|
-
* trusts (ISRG Root X1). If Let's Encrypt rotates this intermediate, this
|
|
207
|
-
* stops working and every Kubbealtı call fails closed to `null` — same
|
|
208
|
-
* fail-closed contract as the rest of this file's fragile integrations.
|
|
209
|
-
*/
|
|
210
|
-
private static readonly KUBBEALTI_EXTRA_CA = [
|
|
211
|
-
`-----BEGIN CERTIFICATE-----
|
|
212
|
-
MIIE2jCCAsKgAwIBAgIQTr0klH4k05SALYSlL9WzGTANBgkqhkiG9w0BAQsFADAu
|
|
213
|
-
MQswCQYDVQQGEwJVUzENMAsGA1UEChMESVNSRzEQMA4GA1UEAxMHUm9vdCBZUjAe
|
|
214
|
-
Fw0yNTA5MDMwMDAwMDBaFw0yODA5MDIyMzU5NTlaMDMxCzAJBgNVBAYTAlVTMRYw
|
|
215
|
-
FAYDVQQKEw1MZXQncyBFbmNyeXB0MQwwCgYDVQQDEwNZUjIwggEiMA0GCSqGSIb3
|
|
216
|
-
DQEBAQUAA4IBDwAwggEKAoIBAQDZ0LxwBppqh84luqMerV/eeL/fXQ7mLQQv1Lnp
|
|
217
|
-
WKZbyvGpx6wh6AfnslAnF6ewTkcHA+gSOoBvm3Dfm06AuGiF+KRut4fAcowqnAQQ
|
|
218
|
-
CW98+QPP/eOv/wug7Iyk4NkOxf2I6g2f55T6nJoOTLFcukeRq80JGQEYan+dPFr9
|
|
219
|
-
OGUgQK2hGKgNkW87pappsOAuUJcroYhRt5uUis4qaZireiseu32gzDJNBAiKtsvd
|
|
220
|
-
6HX4v25bpkRNcS/B/Gtc9kVbUpD+2PLPxdei3Tim55k4tfAEXwD2qyiPTxrTNq6l
|
|
221
|
-
N+AMr5g2c1dNqkOTwjxeV6L5lpP1rGiYvLnRaPlOqyZRPW+5AgMBAAGjge4wgesw
|
|
222
|
-
DgYDVR0PAQH/BAQDAgGGMBMGA1UdJQQMMAoGCCsGAQUFBwMBMBIGA1UdEwEB/wQI
|
|
223
|
-
MAYBAf8CAQAwHQYDVR0OBBYEFEAVLSZ57TIgnt+ach3WMh+BDIEMMB8GA1UdIwQY
|
|
224
|
-
MBaAFN7nW2DQIm1AKH0/DQH+pLVStFGUMDIGCCsGAQUFBwEBBCYwJDAiBggrBgEF
|
|
225
|
-
BQcwAoYWaHR0cDovL3lyLmkubGVuY3Iub3JnLzATBgNVHSAEDDAKMAgGBmeBDAEC
|
|
226
|
-
ATAnBgNVHR8EIDAeMBygGqAYhhZodHRwOi8veXIuYy5sZW5jci5vcmcvMA0GCSqG
|
|
227
|
-
SIb3DQEBCwUAA4ICAQB0ZUQWZ9/Yn9COEpo+JfecMnB0h0vwDm/M66IqXqw3LoaL
|
|
228
|
-
mx9lZvRTeDIS67PUeI3yCA2W6PKRD0/FE/G57lOmS+Xy5AaaL00ICGOqjNcCaMWW
|
|
229
|
-
8o8nevHOd4i4lqgtznE/28QwlcdJyF8yBiWHpnyjhEpmNWJURgOCOg2xpwRMBCsj
|
|
230
|
-
MScqYPtOhBeuYQvSwAEeTML2Ukh6uGuX4E14q65Ja8cdjF5bAldnP1eE4FBaAwsZ
|
|
231
|
-
G2fOqqrKV03Y85Nw2btedP1AtliQuJZs/Jo/gXxXdc7LrH3McgnpnbTiAncX7yES
|
|
232
|
-
hP6kzQejllqMCIt52HOjxDGWafS7Xw+DKwqmH+Eqy8dcbOuag/1AYlQoKNVK3F5q
|
|
233
|
-
Hh6tEDiMqQcLIibGKteE6iHo4A/bIScbzrhXUYuism42ZYzmc48FMVIH3qy4L84E
|
|
234
|
-
TdAH2gtxw0PAhvRVXp8HP7wfngpzsN/8xOTpeRSbM4+Qbc56G6+Bifmv6sk1ieQb
|
|
235
|
-
NA3wJdl4DDUuQSV8hBgx6zoI1ZSGORprDFux7c6rhc77QZMSRrEgomBeklervEve
|
|
236
|
-
86ylWmZ3WWHV6RLMi8xNvjd71r4EPIGgY7BZU/VPBkq+uA7Gb6mbJnFgV43uh3xy
|
|
237
|
-
LRFgxIAphIukwTGSMZZR+AI+Qnp0BYTWovHXozOf3H8r6hozEoT02JHn0AeTfA==
|
|
238
|
-
-----END CERTIFICATE-----`,
|
|
239
|
-
`-----BEGIN CERTIFICATE-----
|
|
240
|
-
MIIF9DCCA9ygAwIBAgIRAPJLbRf52a18scn+p4eCaZ8wDQYJKoZIhvcNAQELBQAw
|
|
241
|
-
TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh
|
|
242
|
-
cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMjYwNTEzMDAwMDAw
|
|
243
|
-
WhcNMzIwOTAyMjM1OTU5WjAuMQswCQYDVQQGEwJVUzENMAsGA1UEChMESVNSRzEQ
|
|
244
|
-
MA4GA1UEAxMHUm9vdCBZUjCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIB
|
|
245
|
-
ANvGJnN78CTJdWL3+eGfsLN5TrNBJs+VH9hRXqRbwxu9sGNiB0BD1fcOxbSUQCJI
|
|
246
|
-
M1xE13Db+5Cw1w0s0EBYsvuIP/6joF0w8cuImbgR1OGgYbSQ4OpzI+DG8SGuTlcE
|
|
247
|
-
873OCS+kh3srlo6vl43M5OJg4Aeo1sfHp6kTJDoIiFBNJAY+OKfX/FUvYKuhjT+n
|
|
248
|
-
o49lmqmupSBI5PkBQiqrEGtWU5uxU/cQWHGu8jSjFBznZqvbNPLMXMLFxCb3WTfr
|
|
249
|
-
JBXXjqvWG+v4bjzxjjeAtOlU7qarRDvNOyAuQYLln904M+faKx8hnLCpJ15ZqaEg
|
|
250
|
-
cNlY+9MMWcC5yvL2A2j3l9+2buggZX+dOE91zYmIdawTvSZuVvlbRrAlLxIB6pwM
|
|
251
|
-
BjneXCjYQ8+3BCCjssbSNpZU3hTcBDdhfAlEDlYr6pEatnMdmDT5BqnKC92bd0Eh
|
|
252
|
-
M1fbLHioLccLCuievT8ZkPhZrq7Mii7gNXAcUEAR8+lzYal+9zTg7C5DALyVOeG/
|
|
253
|
-
CqfRAMn1KSHCR0NSA6P8tn/mGRlnCct5rtVCLnVySVpU6H1qGg3DgTOuskf8eahT
|
|
254
|
-
MiYbI5ezPJmO5ertalskQ1utp74+eDy92PI4ftHKTbq9IWhH4YZKh3WnJEIt+oQv
|
|
255
|
-
lYZbY8tpEroKrFB6PFGzrJIDRyts4HqvuH52RFj2zv/BAgMBAAGjgeswgegwDgYD
|
|
256
|
-
VR0PAQH/BAQDAgEGMBMGA1UdJQQMMAoGCCsGAQUFBwMBMA8GA1UdEwEB/wQFMAMB
|
|
257
|
-
Af8wHQYDVR0OBBYEFN7nW2DQIm1AKH0/DQH+pLVStFGUMB8GA1UdIwQYMBaAFHm0
|
|
258
|
-
WeZ7tuXkAXOACIjIGlj26ZtuMDIGCCsGAQUFBwEBBCYwJDAiBggrBgEFBQcwAoYW
|
|
259
|
-
aHR0cDovL3gxLmkubGVuY3Iub3JnLzATBgNVHSAEDDAKMAgGBmeBDAECATAnBgNV
|
|
260
|
-
HR8EIDAeMBygGqAYhhZodHRwOi8veDEuYy5sZW5jci5vcmcvMA0GCSqGSIb3DQEB
|
|
261
|
-
CwUAA4ICAQA8spSI95KKfn2W6GMmDpHBJSPaLbsS3W93cijJCRCYAc1fsJgL1FIL
|
|
262
|
-
7C0C9ecPOdcwB2fi0Dk2p94j9iTJCxmt5CFSKLRWwnXT2MMSXexVxqoVB79BdWPx
|
|
263
|
-
VXETkVme/qYSAuKVHh5Ps+5BixgmwS1JkjSAc+MfrUbNssVEEnH0aEiAh+rotXAV
|
|
264
|
-
JSP/Ye7LJPEwD9DWG72vVWbhAcuOf5OLjz57Ctk7MgQHynZ7+PlHJtajroCaIbtC
|
|
265
|
-
r6tcZZaAwUQm+jQyeWdV+2hv9deOYFmKeQyjjcSrN5Nadrw+L9DZJLbA1HqeNvLh
|
|
266
|
-
BgqpP0fvJq2N6EtD574N6eMI7uMsJTnji2UDz9el5XLSv9fqJMuDQtYVb2oTNoKp
|
|
267
|
-
oUqhxPVC0aq4eG5MESaIdn8b5ZGSSeAJLMHXljEdlNza+ncfkviXk1POLnnFdvx8
|
|
268
|
-
/gk6M374WbLWFXw8N141B/Rl/tINGfl1TxOIiqtiMYkL02RSGb1kq34BL9NPP27z
|
|
269
|
-
RGMuHGnzS3hFIrRTfKxrzUZ9RzQWzEG3K6fJ3r2nqSltkeytis9DIBoFY9VmVyjL
|
|
270
|
-
M71DMi+y1+TRSJVClEMwvA4yL++7q9XZx5r5wBRWB4kQTKH5qyoZnDw7iiuh1lID
|
|
271
|
-
yDFx8r7i9vIJU5HS3moZLkYWAOilMaV9N56A9Bgb6dNcHkvg3NoaYA==
|
|
272
|
-
-----END CERTIFICATE-----`,
|
|
273
|
-
];
|
|
274
|
-
|
|
275
|
-
// Configuration
|
|
276
|
-
private static defaultTimeoutMs = 8000;
|
|
277
|
-
private static defaultRetries = 1;
|
|
278
|
-
private static maxCacheSize = 1000;
|
|
279
|
-
|
|
280
|
-
// Cache Mechanism
|
|
281
|
-
private static isCacheEnabled = false;
|
|
282
|
-
private static wordCache = new Map<string, WordInfo[]>();
|
|
283
|
-
private static dailyContentCache: DailyContent | null = null;
|
|
284
|
-
private static autocompleteCache: string[] = [];
|
|
285
|
-
private static autocompleteSet: Set<string> = new Set<string>();
|
|
286
|
-
private static stemCache = new Map<string, string | null>();
|
|
287
|
-
|
|
288
|
-
/**
|
|
289
|
-
* Configures global client options such as network timeout, retries, and cache size.
|
|
290
|
-
*/
|
|
291
|
-
public static configure(config: TDKConfig): void {
|
|
292
|
-
if (config.timeoutMs !== undefined) this.defaultTimeoutMs = Math.max(100, config.timeoutMs);
|
|
293
|
-
if (config.retries !== undefined) this.defaultRetries = Math.max(0, config.retries);
|
|
294
|
-
if (config.cache !== undefined) this.enableCache(config.cache);
|
|
295
|
-
if (config.maxCacheSize !== undefined) this.maxCacheSize = Math.max(10, config.maxCacheSize);
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
/**
|
|
299
|
-
* Enables or disables in-memory caching for API requests.
|
|
300
|
-
*/
|
|
301
|
-
public static enableCache(status = true): void {
|
|
302
|
-
this.isCacheEnabled = status;
|
|
303
|
-
if (!status) {
|
|
304
|
-
this.clearCache();
|
|
305
|
-
}
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
/**
|
|
309
|
-
* Clears the internal cache.
|
|
310
|
-
*/
|
|
311
|
-
public static clearCache(): void {
|
|
312
|
-
this.wordCache.clear();
|
|
313
|
-
this.dailyContentCache = null;
|
|
314
|
-
this.autocompleteCache = [];
|
|
315
|
-
this.autocompleteSet.clear();
|
|
316
|
-
this.stemCache.clear();
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
private static setBoundedCache<K, V>(map: Map<K, V>, key: K, value: V): void {
|
|
320
|
-
if (map.size >= this.maxCacheSize) {
|
|
321
|
-
const firstKey = map.keys().next().value;
|
|
322
|
-
if (firstKey !== undefined) map.delete(firstKey);
|
|
323
|
-
}
|
|
324
|
-
map.set(key, value);
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
private static delay(ms: number) {
|
|
328
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
/**
|
|
332
|
-
* Internal helper that performs HTTP fetch with timeout and automatic retry on network/5xx errors.
|
|
333
|
-
*/
|
|
334
|
-
private static async fetchWithRetry(
|
|
335
|
-
url: string,
|
|
336
|
-
options: RequestInit = {},
|
|
337
|
-
retries: number = this.defaultRetries,
|
|
338
|
-
timeoutMs: number = this.defaultTimeoutMs
|
|
339
|
-
): Promise<Response> {
|
|
340
|
-
let lastError: unknown;
|
|
341
|
-
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
342
|
-
try {
|
|
343
|
-
const signal = AbortSignal.timeout(timeoutMs);
|
|
344
|
-
const headers = {
|
|
345
|
-
"User-Agent": "TDK-API-Nodejs-Wrapper/1.0",
|
|
346
|
-
...((options.headers as Record<string, string>) || {}),
|
|
347
|
-
};
|
|
348
|
-
const res = await fetch(url, { ...options, headers, signal });
|
|
349
|
-
if (res.ok || (res.status >= 400 && res.status < 500)) {
|
|
350
|
-
return res;
|
|
351
|
-
}
|
|
352
|
-
// If 5xx server error, retry
|
|
353
|
-
if (attempt < retries) {
|
|
354
|
-
await this.delay(200 * (attempt + 1));
|
|
355
|
-
continue;
|
|
356
|
-
}
|
|
357
|
-
return res;
|
|
358
|
-
} catch (err) {
|
|
359
|
-
lastError = err;
|
|
360
|
-
if (attempt < retries) {
|
|
361
|
-
await this.delay(200 * (attempt + 1));
|
|
362
|
-
continue;
|
|
363
|
-
}
|
|
364
|
-
}
|
|
365
|
-
}
|
|
366
|
-
throw new TDKNetworkError(`Request to ${url} failed after ${retries + 1} attempts.`, {
|
|
367
|
-
cause: lastError,
|
|
368
|
-
});
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
/**
|
|
372
|
-
* Fetches detailed information for a given word from the TDK Dictionary.
|
|
373
|
-
*/
|
|
374
|
-
public static async getWord(word: string): Promise<WordInfo[]> {
|
|
375
|
-
if (!word || word.trim() === "") {
|
|
376
|
-
throw new TDKValidationError("Word parameter cannot be empty.");
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
const cleanWord = word.trim().toLocaleLowerCase("tr-TR");
|
|
380
|
-
|
|
381
|
-
if (this.isCacheEnabled && this.wordCache.has(cleanWord)) {
|
|
382
|
-
return this.wordCache.get(cleanWord)!;
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
const url = `${this.BASE_URL}/gts?ara=${encodeURIComponent(cleanWord)}`;
|
|
386
|
-
|
|
387
|
-
let response: Response;
|
|
388
|
-
try {
|
|
389
|
-
response = await this.fetchWithRetry(url);
|
|
390
|
-
} catch (error) {
|
|
391
|
-
throw new TDKNetworkError("Failed to fetch word from TDK: request failed.", { cause: error });
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
if (!response.ok) {
|
|
395
|
-
throw new TDKNetworkError(`Failed to fetch word from TDK: HTTP ${response.status}.`, {
|
|
396
|
-
status: response.status,
|
|
397
|
-
});
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
let data: unknown;
|
|
401
|
-
try {
|
|
402
|
-
data = await response.json();
|
|
403
|
-
} catch (error) {
|
|
404
|
-
throw new TDKNetworkError("Failed to fetch word from TDK: invalid JSON response.", { cause: error });
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
if (!Array.isArray(data) && data && "error" in (data as Record<string, unknown>)) {
|
|
408
|
-
if (this.isCacheEnabled) this.setBoundedCache(this.wordCache, cleanWord, []);
|
|
409
|
-
return [];
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
const results = data as WordInfo[];
|
|
413
|
-
if (this.isCacheEnabled) {
|
|
414
|
-
this.setBoundedCache(this.wordCache, cleanWord, results);
|
|
415
|
-
}
|
|
416
|
-
return results;
|
|
417
|
-
}
|
|
418
|
-
|
|
419
|
-
/**
|
|
420
|
-
* Helper method to get only the meanings (definitions) of a word as a string array.
|
|
421
|
-
*/
|
|
422
|
-
public static async getMeanings(word: string): Promise<string[]> {
|
|
423
|
-
const results = await this.getWord(word);
|
|
424
|
-
if (results.length === 0) return [];
|
|
425
|
-
|
|
426
|
-
const meanings: string[] = [];
|
|
427
|
-
for (const result of results) {
|
|
428
|
-
if (result.anlamlarListe) {
|
|
429
|
-
for (const anlam of result.anlamlarListe) {
|
|
430
|
-
if (anlam.anlam) meanings.push(anlam.anlam);
|
|
431
|
-
}
|
|
432
|
-
}
|
|
433
|
-
}
|
|
434
|
-
return meanings;
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
/**
|
|
438
|
-
* `sozluk.gov.tr`'s dedicated `/autocomplete.json` (and `/data/autocomplete.json`)
|
|
439
|
-
* routes no longer serve JSON — they fall through to the SPA's `index.html`.
|
|
440
|
-
* The full ~81k-word headword list the site's own autocomplete UI uses is
|
|
441
|
-
* instead bundled directly into its main JS asset as a
|
|
442
|
-
* `JSON.parse(\`[{"madde":"..."}]\`)` literal, so this fetches the home
|
|
443
|
-
* page to find that asset's current hashed filename, downloads it (a few
|
|
444
|
-
* MB, only once per process), and extracts the literal out of it. Fragile
|
|
445
|
-
* scraping of an implementation detail — if TDK's build stops embedding
|
|
446
|
-
* this, this fails closed to `[]` rather than throwing.
|
|
447
|
-
*/
|
|
448
|
-
private static async fetchAutocompleteData(): Promise<string[]> {
|
|
449
|
-
try {
|
|
450
|
-
const homeResponse = await fetch(`${this.BASE_URL}/`, {
|
|
451
|
-
headers: { "User-Agent": "TDK-API-Nodejs-Wrapper/1.0" },
|
|
452
|
-
});
|
|
453
|
-
if (!homeResponse.ok) return [];
|
|
454
|
-
const html = await homeResponse.text();
|
|
455
|
-
|
|
456
|
-
const scriptMatch = html.match(/src="(\/assets\/index-[^"]+\.js)"/);
|
|
457
|
-
if (!scriptMatch) return [];
|
|
458
|
-
|
|
459
|
-
const bundleResponse = await fetch(`${this.BASE_URL}${scriptMatch[1]}`, {
|
|
460
|
-
headers: { "User-Agent": "TDK-API-Nodejs-Wrapper/1.0" },
|
|
461
|
-
});
|
|
462
|
-
if (!bundleResponse.ok) return [];
|
|
463
|
-
const bundleJs = await bundleResponse.text();
|
|
464
|
-
|
|
465
|
-
const startMarker = 'JSON.parse(`[{"madde":';
|
|
466
|
-
const startIdx = bundleJs.indexOf(startMarker);
|
|
467
|
-
if (startIdx === -1) return [];
|
|
468
|
-
const jsonStart = startIdx + "JSON.parse(".length + 1;
|
|
469
|
-
const jsonEnd = bundleJs.indexOf("`)", jsonStart);
|
|
470
|
-
if (jsonEnd === -1) return [];
|
|
471
|
-
|
|
472
|
-
const data = JSON.parse(bundleJs.slice(jsonStart, jsonEnd)) as { madde: string }[];
|
|
473
|
-
return data.map((item) => item.madde).filter(Boolean);
|
|
474
|
-
} catch {
|
|
475
|
-
return [];
|
|
476
|
-
}
|
|
477
|
-
}
|
|
478
|
-
|
|
479
|
-
/**
|
|
480
|
-
* Ensures TDK's ~81k headword list is loaded in memory for fast O(1) set operations.
|
|
481
|
-
*/
|
|
482
|
-
private static async ensureAutocompleteLoaded(): Promise<void> {
|
|
483
|
-
if (this.autocompleteCache.length === 0) {
|
|
484
|
-
this.autocompleteCache = await this.fetchAutocompleteData();
|
|
485
|
-
this.autocompleteSet = new Set(
|
|
486
|
-
this.autocompleteCache.map((w) => w.toLocaleLowerCase("tr-TR"))
|
|
487
|
-
);
|
|
488
|
-
}
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
/**
|
|
492
|
-
* Returns autocomplete suggestions for a given prefix, searched over TDK's
|
|
493
|
-
* full headword list (see `fetchAutocompleteData`). The list is fetched
|
|
494
|
-
* and cached once per process regardless of `enableCache()` — the same
|
|
495
|
-
* caching behavior as before — and only cleared by `clearCache()`.
|
|
496
|
-
*/
|
|
497
|
-
public static async getSuggestions(prefix: string): Promise<string[]> {
|
|
498
|
-
if (!prefix || prefix.trim() === "") return [];
|
|
499
|
-
|
|
500
|
-
await this.ensureAutocompleteLoaded();
|
|
501
|
-
|
|
502
|
-
const cleanPrefix = prefix.trim().toLocaleLowerCase("tr-TR");
|
|
503
|
-
return this.autocompleteCache
|
|
504
|
-
.filter(w => w.toLocaleLowerCase("tr-TR").startsWith(cleanPrefix))
|
|
505
|
-
.slice(0, 10);
|
|
506
|
-
}
|
|
507
|
-
|
|
508
|
-
/**
|
|
509
|
-
* Checks whether a word exists as a known headword in TDK dictionary.
|
|
510
|
-
* Checks in-memory autocompleteSet (81k headwords) if loaded, or queries TDK API.
|
|
511
|
-
*/
|
|
512
|
-
public static async isHeadword(word: string): Promise<boolean> {
|
|
513
|
-
if (!word || word.trim() === "") return false;
|
|
514
|
-
const clean = word.trim().toLocaleLowerCase("tr-TR");
|
|
515
|
-
|
|
516
|
-
await this.ensureAutocompleteLoaded();
|
|
517
|
-
if (this.autocompleteSet.size > 0) {
|
|
518
|
-
return this.autocompleteSet.has(clean);
|
|
519
|
-
}
|
|
520
|
-
|
|
521
|
-
try {
|
|
522
|
-
const results = await this.getWord(clean);
|
|
523
|
-
return results.length > 0;
|
|
524
|
-
} catch {
|
|
525
|
-
return false;
|
|
526
|
-
}
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
/**
|
|
530
|
-
* Generates candidate roots for a given Turkish word using progressive BFS suffix stripping,
|
|
531
|
-
* consonant mutation restoration, and vowel drop restoration.
|
|
532
|
-
*/
|
|
533
|
-
public static getStemCandidates(word: string): string[] {
|
|
534
|
-
return getStemCandidates(word);
|
|
535
|
-
}
|
|
536
|
-
|
|
537
|
-
/**
|
|
538
|
-
* Finds the dictionary root (headword) of a word by checking direct existence
|
|
539
|
-
* and evaluating candidate stems generated by morphological analysis.
|
|
540
|
-
* Returns the root headword string if found, or null if no match in TDK.
|
|
541
|
-
*/
|
|
542
|
-
public static async findRoot(word: string): Promise<string | null> {
|
|
543
|
-
if (!word || word.trim() === "") return null;
|
|
544
|
-
const clean = word.trim().toLocaleLowerCase("tr-TR");
|
|
545
|
-
|
|
546
|
-
if (this.stemCache.has(clean)) {
|
|
547
|
-
return this.stemCache.get(clean)!;
|
|
548
|
-
}
|
|
549
|
-
|
|
550
|
-
// 1. If the word itself is an exact headword, it is its own root
|
|
551
|
-
if (await this.isHeadword(clean)) {
|
|
552
|
-
this.setBoundedCache(this.stemCache, clean, clean);
|
|
553
|
-
return clean;
|
|
554
|
-
}
|
|
555
|
-
|
|
556
|
-
// 2. Test morphological stem candidates
|
|
557
|
-
const candidates = getStemCandidates(clean);
|
|
558
|
-
for (const candidate of candidates) {
|
|
559
|
-
if (await this.isHeadword(candidate)) {
|
|
560
|
-
this.setBoundedCache(this.stemCache, clean, candidate);
|
|
561
|
-
return candidate;
|
|
562
|
-
}
|
|
563
|
-
}
|
|
564
|
-
|
|
565
|
-
this.setBoundedCache(this.stemCache, clean, null);
|
|
566
|
-
return null;
|
|
567
|
-
}
|
|
568
|
-
|
|
569
|
-
/**
|
|
570
|
-
* Performs morphological stemming on a Turkish word.
|
|
571
|
-
* Returns a StemResult containing the original word, resolved root, and whether it is inflected.
|
|
572
|
-
*/
|
|
573
|
-
public static async stem(word: string): Promise<StemResult | null> {
|
|
574
|
-
if (!word || word.trim() === "") return null;
|
|
575
|
-
const clean = word.trim().toLocaleLowerCase("tr-TR");
|
|
576
|
-
const root = await this.findRoot(word);
|
|
577
|
-
|
|
578
|
-
if (!root) {
|
|
579
|
-
return null;
|
|
580
|
-
}
|
|
581
|
-
|
|
582
|
-
return {
|
|
583
|
-
word,
|
|
584
|
-
root,
|
|
585
|
-
isInflected: root !== clean,
|
|
586
|
-
candidates: getStemCandidates(word),
|
|
587
|
-
};
|
|
588
|
-
}
|
|
589
|
-
|
|
590
|
-
/**
|
|
591
|
-
* Returns a list of proverbs and idioms containing the word.
|
|
592
|
-
*/
|
|
593
|
-
public static async getProverbs(word: string): Promise<string[]> {
|
|
594
|
-
const results = await this.getWord(word);
|
|
595
|
-
if (results.length === 0) return [];
|
|
596
|
-
|
|
597
|
-
const proverbs: string[] = [];
|
|
598
|
-
for (const result of results) {
|
|
599
|
-
if (result.atasozu) {
|
|
600
|
-
for (const atasoz of result.atasozu) {
|
|
601
|
-
if (atasoz.madde) proverbs.push(atasoz.madde);
|
|
602
|
-
}
|
|
603
|
-
}
|
|
604
|
-
}
|
|
605
|
-
return proverbs;
|
|
606
|
-
}
|
|
607
|
-
|
|
608
|
-
/**
|
|
609
|
-
* Returns the etymological origin of the word, or "Türkçe" if TDK doesn't
|
|
610
|
-
* record a foreign origin for it. Returns `null` only when the word itself
|
|
611
|
-
* isn't found in the dictionary at all.
|
|
612
|
-
*/
|
|
613
|
-
public static async getOrigin(word: string): Promise<string | null> {
|
|
614
|
-
const results = await this.getWord(word);
|
|
615
|
-
if (results.length === 0) return null;
|
|
616
|
-
return results[0].lisan || "Türkçe";
|
|
617
|
-
}
|
|
618
|
-
|
|
619
|
-
/**
|
|
620
|
-
* Returns whether the word has a recorded foreign etymological origin.
|
|
621
|
-
* Returns `null` (instead of a boolean) when the word isn't found at all.
|
|
622
|
-
*/
|
|
623
|
-
public static async isForeignWord(word: string): Promise<boolean | null> {
|
|
624
|
-
const origin = await this.getOrigin(word);
|
|
625
|
-
if (origin === null) return null;
|
|
626
|
-
return origin !== "Türkçe";
|
|
627
|
-
}
|
|
628
|
-
|
|
629
|
-
/**
|
|
630
|
-
* Groups a list of words by their etymological origin. Words not found in
|
|
631
|
-
* the dictionary are grouped under "Bilinmiyor". Throttled like getWordsBatch.
|
|
632
|
-
*/
|
|
633
|
-
public static async groupByOrigin(words: string[]): Promise<Record<string, string[]>> {
|
|
634
|
-
const groups: Record<string, string[]> = {};
|
|
635
|
-
for (const word of words) {
|
|
636
|
-
const origin = (await this.getOrigin(word)) ?? "Bilinmiyor";
|
|
637
|
-
if (!groups[origin]) groups[origin] = [];
|
|
638
|
-
groups[origin].push(word);
|
|
639
|
-
await this.delay(200);
|
|
640
|
-
}
|
|
641
|
-
return groups;
|
|
642
|
-
}
|
|
643
|
-
|
|
644
|
-
/**
|
|
645
|
-
* Returns literature examples containing the word.
|
|
646
|
-
*/
|
|
647
|
-
public static async getExamples(word: string): Promise<{ sentence: string; author: string | null }[]> {
|
|
648
|
-
const results = await this.getWord(word);
|
|
649
|
-
const examples: { sentence: string; author: string | null }[] = [];
|
|
650
|
-
|
|
651
|
-
for (const result of results) {
|
|
652
|
-
if (result.anlamlarListe) {
|
|
653
|
-
for (const anlam of result.anlamlarListe) {
|
|
654
|
-
if (anlam.orneklerListe) {
|
|
655
|
-
for (const ornek of anlam.orneklerListe) {
|
|
656
|
-
const author = ornek.yazar && ornek.yazar.length > 0 ? ornek.yazar[0].tam_adi : null;
|
|
657
|
-
examples.push({ sentence: ornek.ornek, author });
|
|
658
|
-
}
|
|
659
|
-
}
|
|
660
|
-
}
|
|
661
|
-
}
|
|
662
|
-
}
|
|
663
|
-
return examples;
|
|
664
|
-
}
|
|
665
|
-
|
|
666
|
-
/**
|
|
667
|
-
* Calls the `api.sozluk.gov.tr/gts-yeni` endpoint the official web UI uses
|
|
668
|
-
* internally (richer than the public `/gts`: includes `seskod`,
|
|
669
|
-
* `anlamEsAnlam`/`anlamKarsitAnlam`, etc). That endpoint 403s unless the
|
|
670
|
-
* request looks like it came from a browser tab on sozluk.gov.tr: it needs
|
|
671
|
-
* an `Origin`/`Referer` pair matching that site AND a browser-like
|
|
672
|
-
* `User-Agent` (our usual `TDK-API-Nodejs-Wrapper/…` UA gets rejected).
|
|
673
|
-
* `fetch` (undici) also strips a manually-set `Origin` header as a
|
|
674
|
-
* forbidden header name, so this uses `node:https` directly instead.
|
|
675
|
-
* This is inherently fragile scraping of an undocumented endpoint — if
|
|
676
|
-
* TDK tightens this check further, this should fail closed to `null`
|
|
677
|
-
* rather than throw.
|
|
678
|
-
*/
|
|
679
|
-
private static fetchGtsYeni(word: string): Promise<any[] | null> {
|
|
680
|
-
return new Promise((resolve) => {
|
|
681
|
-
const req = https.request(
|
|
682
|
-
{
|
|
683
|
-
hostname: this.AUDIO_API_HOST,
|
|
684
|
-
path: `/gts-yeni?ara=${encodeURIComponent(word)}`,
|
|
685
|
-
method: "GET",
|
|
686
|
-
headers: {
|
|
687
|
-
"User-Agent":
|
|
688
|
-
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36",
|
|
689
|
-
Origin: this.BASE_URL,
|
|
690
|
-
Referer: `${this.BASE_URL}/`,
|
|
691
|
-
},
|
|
692
|
-
},
|
|
693
|
-
(res) => {
|
|
694
|
-
let body = "";
|
|
695
|
-
res.on("data", (chunk) => (body += chunk));
|
|
696
|
-
res.on("end", () => {
|
|
697
|
-
try {
|
|
698
|
-
const data = JSON.parse(body);
|
|
699
|
-
resolve(Array.isArray(data) ? data : null);
|
|
700
|
-
} catch {
|
|
701
|
-
resolve(null);
|
|
702
|
-
}
|
|
703
|
-
});
|
|
704
|
-
}
|
|
705
|
-
);
|
|
706
|
-
req.on("error", () => resolve(null));
|
|
707
|
-
req.end();
|
|
708
|
-
});
|
|
709
|
-
}
|
|
710
|
-
|
|
711
|
-
private static async fetchSeskod(word: string): Promise<string | null> {
|
|
712
|
-
const data = await this.fetchGtsYeni(word);
|
|
713
|
-
const seskod = data?.[0]?.seskod;
|
|
714
|
-
return seskod ? String(seskod) : null;
|
|
715
|
-
}
|
|
716
|
-
|
|
717
|
-
/**
|
|
718
|
-
* Returns synonyms ("eş anlamlı kelimeler") recorded for the word, pooled
|
|
719
|
-
* across all of its meanings. Uses the same undocumented `gts-yeni`
|
|
720
|
-
* endpoint as `getAudioUrl` — returns `[]` if the lookup fails.
|
|
721
|
-
*/
|
|
722
|
-
public static async getSynonyms(word: string): Promise<string[]> {
|
|
723
|
-
if (!word || word.trim() === "") return [];
|
|
724
|
-
const data = await this.fetchGtsYeni(word.trim().toLocaleLowerCase("tr-TR"));
|
|
725
|
-
if (!data) return [];
|
|
726
|
-
|
|
727
|
-
const synonyms: string[] = [];
|
|
728
|
-
for (const entry of data) {
|
|
729
|
-
for (const anlam of entry.anlamlarListe ?? []) {
|
|
730
|
-
for (const es of anlam.anlamEsAnlam ?? []) {
|
|
731
|
-
if (es.deger) synonyms.push(es.deger);
|
|
732
|
-
}
|
|
733
|
-
}
|
|
734
|
-
}
|
|
735
|
-
return [...new Set(synonyms)];
|
|
736
|
-
}
|
|
737
|
-
|
|
738
|
-
/**
|
|
739
|
-
* Returns antonyms ("zıt anlamlı kelimeler") recorded for the word, pooled
|
|
740
|
-
* across all of its meanings. Uses the same undocumented `gts-yeni`
|
|
741
|
-
* endpoint as `getAudioUrl` — returns `[]` if the lookup fails.
|
|
742
|
-
*/
|
|
743
|
-
public static async getAntonyms(word: string): Promise<string[]> {
|
|
744
|
-
if (!word || word.trim() === "") return [];
|
|
745
|
-
const data = await this.fetchGtsYeni(word.trim().toLocaleLowerCase("tr-TR"));
|
|
746
|
-
if (!data) return [];
|
|
747
|
-
|
|
748
|
-
const antonyms: string[] = [];
|
|
749
|
-
for (const entry of data) {
|
|
750
|
-
for (const anlam of entry.anlamlarListe ?? []) {
|
|
751
|
-
for (const ka of anlam.anlamKarsitAnlam ?? []) {
|
|
752
|
-
if (ka.deger) antonyms.push(ka.deger);
|
|
753
|
-
}
|
|
754
|
-
}
|
|
755
|
-
}
|
|
756
|
-
return [...new Set(antonyms)];
|
|
757
|
-
}
|
|
758
|
-
|
|
759
|
-
/**
|
|
760
|
-
* Returns the direct URL of the audio pronunciation, if TDK has one recorded for this word.
|
|
761
|
-
*/
|
|
762
|
-
public static async getAudioUrl(word: string): Promise<string | null> {
|
|
763
|
-
if (!word || word.trim() === "") {
|
|
764
|
-
throw new TDKValidationError("Word parameter cannot be empty.");
|
|
765
|
-
}
|
|
766
|
-
|
|
767
|
-
const seskod = await this.fetchSeskod(word.trim().toLocaleLowerCase("tr-TR"));
|
|
768
|
-
if (!seskod) return null;
|
|
769
|
-
return `https://${this.AUDIO_API_HOST}/ses/${encodeURIComponent(seskod)}.wav`;
|
|
770
|
-
}
|
|
771
|
-
|
|
772
|
-
/**
|
|
773
|
-
* Downloads the audio pronunciation to the specified path.
|
|
774
|
-
*/
|
|
775
|
-
public static async downloadAudio(word: string, destPath?: string): Promise<string | null> {
|
|
776
|
-
const url = await this.getAudioUrl(word);
|
|
777
|
-
if (!url) return null;
|
|
778
|
-
|
|
779
|
-
const finalPath = destPath || path.join(os.tmpdir(), `${word}.wav`);
|
|
780
|
-
try {
|
|
781
|
-
const res = await fetch(url);
|
|
782
|
-
if (!res.ok) return null;
|
|
783
|
-
const buffer = await res.arrayBuffer();
|
|
784
|
-
fs.writeFileSync(finalPath, Buffer.from(buffer));
|
|
785
|
-
return finalPath;
|
|
786
|
-
} catch {
|
|
787
|
-
return null;
|
|
788
|
-
}
|
|
789
|
-
}
|
|
790
|
-
|
|
791
|
-
/**
|
|
792
|
-
* Checks spelling and returns suggestions if wrong.
|
|
793
|
-
*/
|
|
794
|
-
public static async checkSpelling(word: string): Promise<SpellCheckResult> {
|
|
795
|
-
if (!word || word.trim() === "") {
|
|
796
|
-
return { isCorrect: false, word };
|
|
797
|
-
}
|
|
798
|
-
|
|
799
|
-
const cleanWord = word.trim().toLocaleLowerCase("tr-TR");
|
|
800
|
-
|
|
801
|
-
// 1. Check if word exists in TDK dictionary
|
|
802
|
-
const results = await this.getWord(word);
|
|
803
|
-
if (results.length > 0) {
|
|
804
|
-
return { isCorrect: true, word };
|
|
805
|
-
}
|
|
806
|
-
|
|
807
|
-
// 2. Common Turkish misspellings, erroneously joined compounds, and vowel drop errors
|
|
808
|
-
if (COMMON_MISSPELLINGS[cleanWord]) {
|
|
809
|
-
return { isCorrect: false, word, suggestion: COMMON_MISSPELLINGS[cleanWord] };
|
|
810
|
-
}
|
|
811
|
-
|
|
812
|
-
// 3. Dynamic -şey / -sey attached check:
|
|
813
|
-
// In Turkish, 'şey' is an indefinite pronoun and is ALWAYS written separately from the preceding word
|
|
814
|
-
// (e.g. her şey, bir şey, hiçbir şey, çok şey, her şeyi, bir şeyler).
|
|
815
|
-
const seyMatch = cleanWord.match(/^(.+?)(?:şey|sey)([ıiuaeüodekmnl]+)?$/);
|
|
816
|
-
if (seyMatch && !SEY_EXCEPTIONS.has(cleanWord)) {
|
|
817
|
-
let prefix = seyMatch[1];
|
|
818
|
-
const suffix = seyMatch[2] || "";
|
|
819
|
-
if (prefix === "hicbir") prefix = "hiçbir";
|
|
820
|
-
if (prefix === "cok") prefix = "çok";
|
|
821
|
-
return {
|
|
822
|
-
isCorrect: false,
|
|
823
|
-
word,
|
|
824
|
-
suggestion: `${prefix} şey${suffix}`,
|
|
825
|
-
};
|
|
826
|
-
}
|
|
827
|
-
|
|
828
|
-
// 4. "Sıkça yapılan yanlışlar" from DailyContent
|
|
829
|
-
const daily = await this.getDailyContent();
|
|
830
|
-
if (daily) {
|
|
831
|
-
const syydMatch = daily.syyd.find((s) => s.yanliskelime.toLocaleLowerCase("tr-TR") === cleanWord);
|
|
832
|
-
if (syydMatch) {
|
|
833
|
-
return { isCorrect: false, word, suggestion: syydMatch.dogrukelime };
|
|
834
|
-
}
|
|
835
|
-
const mixMatch = daily.karistirma.find((s) => s.yanlis.toLocaleLowerCase("tr-TR") === cleanWord);
|
|
836
|
-
if (mixMatch) {
|
|
837
|
-
return { isCorrect: false, word, suggestion: mixMatch.dogru };
|
|
838
|
-
}
|
|
839
|
-
}
|
|
840
|
-
|
|
841
|
-
// 5. Morphology Fallback: Check if the word is an inflected form or bare verb imperative of a known headword
|
|
842
|
-
// (e.g., "halılarımızın" -> "halı", "kitabımız" -> "kitap", "çocuğa" -> "çocuk", "söyle" -> "söylemek")
|
|
843
|
-
const root = await this.findRoot(word);
|
|
844
|
-
if (root) {
|
|
845
|
-
const isInflected = root !== cleanWord;
|
|
846
|
-
return {
|
|
847
|
-
isCorrect: true,
|
|
848
|
-
word,
|
|
849
|
-
isInflected,
|
|
850
|
-
root,
|
|
851
|
-
};
|
|
852
|
-
}
|
|
853
|
-
|
|
854
|
-
// 6. Check if headwords with spaces match when space is removed (e.g. "ön yargı" for "önyargı")
|
|
855
|
-
if (this.autocompleteCache.length === 0) {
|
|
856
|
-
this.autocompleteCache = await this.fetchAutocompleteData();
|
|
857
|
-
}
|
|
858
|
-
for (const candidate of this.autocompleteCache) {
|
|
859
|
-
if (candidate.includes(" ")) {
|
|
860
|
-
const candidateNoSpace = candidate.replace(/\s+/g, "").toLocaleLowerCase("tr-TR");
|
|
861
|
-
if (candidateNoSpace === cleanWord) {
|
|
862
|
-
return { isCorrect: false, word, suggestion: candidate };
|
|
863
|
-
}
|
|
864
|
-
}
|
|
865
|
-
}
|
|
866
|
-
|
|
867
|
-
// 7. No exact match or morphology root: fall back to closest headword by edit distance.
|
|
868
|
-
// Ties prefer matching first letter, and initial character mismatches are penalized
|
|
869
|
-
// so irrelevant foreign loanwords (like 'jersey') do not beat Turkish roots.
|
|
870
|
-
let best: { candidate: string; distance: number; rawDist: number; firstMismatch: number; lengthMismatch: number } | null = null;
|
|
871
|
-
for (const candidate of this.autocompleteCache) {
|
|
872
|
-
if (candidate.includes(" ") || candidate !== candidate.toLocaleLowerCase("tr-TR")) continue;
|
|
873
|
-
if (Math.abs(candidate.length - cleanWord.length) > 2) continue;
|
|
874
|
-
|
|
875
|
-
const rawDist = this.damerauLevenshtein(cleanWord, candidate);
|
|
876
|
-
if (rawDist === 0) continue;
|
|
877
|
-
|
|
878
|
-
const firstMismatch = candidate[0] === cleanWord[0] ? 0 : 1;
|
|
879
|
-
const lengthMismatch = candidate.length === cleanWord.length ? 0 : 1;
|
|
880
|
-
const distance = rawDist + (firstMismatch > 0 ? 1.2 : 0);
|
|
881
|
-
|
|
882
|
-
const better =
|
|
883
|
-
!best ||
|
|
884
|
-
distance < best.distance ||
|
|
885
|
-
(distance === best.distance && firstMismatch < best.firstMismatch) ||
|
|
886
|
-
(distance === best.distance && firstMismatch === best.firstMismatch && lengthMismatch < best.lengthMismatch);
|
|
887
|
-
if (better) {
|
|
888
|
-
best = { candidate, distance, rawDist, firstMismatch, lengthMismatch };
|
|
889
|
-
}
|
|
890
|
-
}
|
|
891
|
-
if (best && best.rawDist <= 2 && (best.firstMismatch === 0 || best.rawDist <= 1)) {
|
|
892
|
-
return { isCorrect: false, word, suggestion: best.candidate };
|
|
893
|
-
}
|
|
894
|
-
return { isCorrect: false, word };
|
|
895
|
-
}
|
|
896
|
-
|
|
897
|
-
/**
|
|
898
|
-
* Fetches daily content (word of the day, proverbs, rules, etc).
|
|
899
|
-
* `bypassCache` skips both reading and writing `dailyContentCache` even
|
|
900
|
-
* when `enableCache(true)` is on — used by `getRule()`'s retry loop, which
|
|
901
|
-
* needs a fresh random `/icerik` draw on every attempt; without it, once
|
|
902
|
-
* caching is enabled the loop would just re-read the same cached response
|
|
903
|
-
* 25 times and could never find a rule outside that first random draw.
|
|
904
|
-
*/
|
|
905
|
-
public static async getDailyContent(bypassCache = false): Promise<DailyContent | null> {
|
|
906
|
-
if (!bypassCache && this.isCacheEnabled && this.dailyContentCache) return this.dailyContentCache;
|
|
907
|
-
|
|
908
|
-
try {
|
|
909
|
-
const response = await fetch(`${this.BASE_URL}/icerik`, {
|
|
910
|
-
headers: { "User-Agent": "TDK-API-Nodejs-Wrapper/1.0" },
|
|
911
|
-
});
|
|
912
|
-
if (response.ok) {
|
|
913
|
-
const data = await response.json() as DailyContent;
|
|
914
|
-
if (!bypassCache && this.isCacheEnabled) this.dailyContentCache = data;
|
|
915
|
-
return data;
|
|
916
|
-
}
|
|
917
|
-
} catch {
|
|
918
|
-
return null;
|
|
919
|
-
}
|
|
920
|
-
return null;
|
|
921
|
-
}
|
|
922
|
-
|
|
923
|
-
/**
|
|
924
|
-
* Returns today's word of the day along with all of its listed meanings.
|
|
925
|
-
*/
|
|
926
|
-
public static async getWordOfTheDay(): Promise<WordOfTheDay | null> {
|
|
927
|
-
const daily = await this.getDailyContent();
|
|
928
|
-
if (!daily || daily.kelime.length === 0) return null;
|
|
929
|
-
|
|
930
|
-
const word = daily.kelime[0].madde;
|
|
931
|
-
const meanings = daily.kelime.filter((k) => k.madde === word).map((k) => k.anlam);
|
|
932
|
-
return { word, meanings };
|
|
933
|
-
}
|
|
934
|
-
|
|
935
|
-
/**
|
|
936
|
-
* Picks a random entry (word or proverb) from today's daily content.
|
|
937
|
-
* Note: this samples from today's `getDailyContent()` picks, not the full dictionary.
|
|
938
|
-
*/
|
|
939
|
-
public static async getRandomWord(): Promise<DailyPick | null> {
|
|
940
|
-
const daily = await this.getDailyContent();
|
|
941
|
-
if (!daily) return null;
|
|
942
|
-
|
|
943
|
-
const pool: DailyPick[] = [
|
|
944
|
-
...daily.kelime.map((k) => ({ type: "kelime" as const, madde: k.madde, anlam: k.anlam })),
|
|
945
|
-
...daily.atasoz.map((a) => ({ type: "atasoz" as const, madde: a.madde, anlam: a.anlam })),
|
|
946
|
-
];
|
|
947
|
-
if (pool.length === 0) return null;
|
|
948
|
-
|
|
949
|
-
return pool[Math.floor(Math.random() * pool.length)];
|
|
950
|
-
}
|
|
951
|
-
|
|
952
|
-
/**
|
|
953
|
-
* Returns the spelling-rule page(s) ("yazım kuralları") linked from TDK's
|
|
954
|
-
* `/icerik` daily-content feed, e.g. `{ adi: "Kısaltmalar", url: "https://..." }`.
|
|
955
|
-
* Note: like `getRandomWord()`, this is NOT a fixed catalog — `/icerik`
|
|
956
|
-
* appears to hand back a single randomly-rotated rule per request, so two
|
|
957
|
-
* calls a second apart can return entirely different rules. `bypassCache`
|
|
958
|
-
* (used internally by `getRule()`'s retry loop) forces a fresh `/icerik`
|
|
959
|
-
* draw even when `enableCache(true)` is on.
|
|
960
|
-
*/
|
|
961
|
-
public static async getKurallar(bypassCache = false): Promise<TDKRule[]> {
|
|
962
|
-
const daily = await this.getDailyContent(bypassCache);
|
|
963
|
-
return daily?.kural ?? [];
|
|
964
|
-
}
|
|
965
|
-
|
|
966
|
-
/**
|
|
967
|
-
* Fetches the full plain-text content of a named spelling rule (matched
|
|
968
|
-
* case-insensitively, substring match) from `tdk.gov.tr`. Since `/icerik`
|
|
969
|
-
* hands back a single randomly-rotated rule per request (out of a pool of
|
|
970
|
-
* roughly twenty) rather than a fixed catalog, a single `getKurallar()`
|
|
971
|
-
* draw would rarely match a given name — this re-draws until it finds a
|
|
972
|
-
* match or gives up. Draws happen in concurrent batches (each `/icerik`
|
|
973
|
-
* request is independent and stateless) rather than one-at-a-time with a
|
|
974
|
-
* delay: same total sample size (25) and hit probability as a sequential
|
|
975
|
-
* loop, but bounded to a handful of round-trips instead of 25 of them, so
|
|
976
|
-
* a miss resolves in roughly one round-trip time instead of several
|
|
977
|
-
* seconds. Every draw bypasses `dailyContentCache` — without that, once
|
|
978
|
-
* `enableCache(true)` is on, every attempt would just re-read the same
|
|
979
|
-
* cached `/icerik` response and could never find a rule outside whatever
|
|
980
|
-
* the first draw happened to be. Returns `null` if no match turns up
|
|
981
|
-
* within the attempt budget or the matched page can't be parsed.
|
|
982
|
-
*/
|
|
983
|
-
public static async getRule(name: string): Promise<string | null> {
|
|
984
|
-
if (!name || name.trim() === "") return null;
|
|
985
|
-
const target = name.trim().toLocaleLowerCase("tr-TR");
|
|
986
|
-
|
|
987
|
-
const BATCH_SIZE = 5;
|
|
988
|
-
const ROUNDS = 5;
|
|
989
|
-
for (let round = 0; round < ROUNDS; round++) {
|
|
990
|
-
const batches = await Promise.all(
|
|
991
|
-
Array.from({ length: BATCH_SIZE }, () => this.getKurallar(true))
|
|
992
|
-
);
|
|
993
|
-
for (const rules of batches) {
|
|
994
|
-
const match = rules.find((r) => r.adi.toLocaleLowerCase("tr-TR").includes(target));
|
|
995
|
-
if (match) return this.fetchRuleText(match.url);
|
|
996
|
-
}
|
|
997
|
-
}
|
|
998
|
-
return null;
|
|
999
|
-
}
|
|
1000
|
-
|
|
1001
|
-
/**
|
|
1002
|
-
* `tdk.gov.tr` rule pages are WordPress/Avada-themed. The actual article
|
|
1003
|
-
* text lives in `<div ... itemprop="text">...</div>` right before a
|
|
1004
|
-
* `<footer class="entry...">` (share buttons, author box, structured-data
|
|
1005
|
-
* spans) — cutting there avoids that trailing cruft.
|
|
1006
|
-
*/
|
|
1007
|
-
private static async fetchRuleText(url: string): Promise<string | null> {
|
|
1008
|
-
try {
|
|
1009
|
-
const response = await fetch(url, { headers: { "User-Agent": "TDK-API-Nodejs-Wrapper/1.0" } });
|
|
1010
|
-
if (!response.ok) return null;
|
|
1011
|
-
const html = await response.text();
|
|
1012
|
-
|
|
1013
|
-
const marker = html.indexOf('itemprop="text"');
|
|
1014
|
-
if (marker === -1) return null;
|
|
1015
|
-
const contentStart = html.indexOf(">", marker) + 1;
|
|
1016
|
-
const contentEnd = html.indexOf("<footer", contentStart);
|
|
1017
|
-
if (contentEnd === -1) return null;
|
|
1018
|
-
|
|
1019
|
-
return this.htmlToPlainText(html.slice(contentStart, contentEnd));
|
|
1020
|
-
} catch {
|
|
1021
|
-
return null;
|
|
1022
|
-
}
|
|
1023
|
-
}
|
|
1024
|
-
|
|
1025
|
-
private static htmlToPlainText(html: string): string {
|
|
1026
|
-
return html
|
|
1027
|
-
.replace(/<br\s*\/?>/gi, "\n")
|
|
1028
|
-
.replace(/<\/(p|div)>/gi, "\n\n")
|
|
1029
|
-
.replace(/<[^>]+>/g, "")
|
|
1030
|
-
.replace(/ /gi, " ")
|
|
1031
|
-
.replace(/</gi, "<")
|
|
1032
|
-
.replace(/>/gi, ">")
|
|
1033
|
-
.replace(/"/gi, '"')
|
|
1034
|
-
.replace(/'|’/gi, "'")
|
|
1035
|
-
.replace(/&/gi, "&")
|
|
1036
|
-
.replace(/[ \t]+/g, " ")
|
|
1037
|
-
.replace(/[ \t]*\n[ \t]*/g, "\n")
|
|
1038
|
-
.replace(/\n{3,}/g, "\n\n")
|
|
1039
|
-
.trim();
|
|
1040
|
-
}
|
|
1041
|
-
|
|
1042
|
-
/**
|
|
1043
|
-
* GETs a JSON path from Kubbealtı Lugatı's data API (`eski.lugatim.com`),
|
|
1044
|
-
* supplying `KUBBEALTI_EXTRA_CA` to work around that host's incomplete
|
|
1045
|
-
* certificate chain (see the constant's doc comment). Fails closed to
|
|
1046
|
-
* `null` on any error — network, TLS, HTTP, or JSON parse.
|
|
1047
|
-
*/
|
|
1048
|
-
private static fetchKubbealtiJson(path: string): Promise<any> {
|
|
1049
|
-
return new Promise((resolve) => {
|
|
1050
|
-
const req = https.request(
|
|
1051
|
-
{
|
|
1052
|
-
hostname: this.KUBBEALTI_HOST,
|
|
1053
|
-
path,
|
|
1054
|
-
method: "GET",
|
|
1055
|
-
ca: [...tls.rootCertificates, ...this.KUBBEALTI_EXTRA_CA],
|
|
1056
|
-
headers: {
|
|
1057
|
-
"User-Agent":
|
|
1058
|
-
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36",
|
|
1059
|
-
},
|
|
1060
|
-
},
|
|
1061
|
-
(res) => {
|
|
1062
|
-
if (res.statusCode !== 200) {
|
|
1063
|
-
res.resume();
|
|
1064
|
-
resolve(null);
|
|
1065
|
-
return;
|
|
1066
|
-
}
|
|
1067
|
-
let body = "";
|
|
1068
|
-
res.on("data", (chunk) => (body += chunk));
|
|
1069
|
-
res.on("end", () => {
|
|
1070
|
-
try {
|
|
1071
|
-
resolve(JSON.parse(body));
|
|
1072
|
-
} catch {
|
|
1073
|
-
resolve(null);
|
|
1074
|
-
}
|
|
1075
|
-
});
|
|
1076
|
-
}
|
|
1077
|
-
);
|
|
1078
|
-
req.on("error", () => resolve(null));
|
|
1079
|
-
req.end();
|
|
1080
|
-
});
|
|
1081
|
-
}
|
|
1082
|
-
|
|
1083
|
-
/**
|
|
1084
|
-
* Kubbealtı indexes headwords with full classical Turkish orthography,
|
|
1085
|
-
* including letters that a plain-ASCII-ish query tends to drop — most
|
|
1086
|
-
* commonly ü/ö/ç/ğ/ş, but also the circumflex ("düzeltme işareti") used in
|
|
1087
|
-
* Arabic/Persian loanwords like "rüzgâr". A search for "ruzgar" misses
|
|
1088
|
-
* entirely (verified: even "ruzgâr" alone still misses — it's the missing
|
|
1089
|
-
* ü, not the missing â, that actually breaks the match). This generates
|
|
1090
|
-
* single-letter-substitution variants to retry, one substitution per
|
|
1091
|
-
* variant (not combinatorial) — covers the overwhelmingly common case of
|
|
1092
|
-
* one "de-Turkished" letter without an explosion of API calls for words
|
|
1093
|
-
* with several.
|
|
1094
|
-
*/
|
|
1095
|
-
private static readonly TURKISH_DEASCII_MAP: Record<string, string[]> = {
|
|
1096
|
-
a: ["â"],
|
|
1097
|
-
i: ["ı", "î"],
|
|
1098
|
-
o: ["ö"],
|
|
1099
|
-
u: ["ü", "û"],
|
|
1100
|
-
c: ["ç"],
|
|
1101
|
-
g: ["ğ"],
|
|
1102
|
-
s: ["ş"],
|
|
1103
|
-
};
|
|
1104
|
-
|
|
1105
|
-
private static generateTurkishVariants(word: string): string[] {
|
|
1106
|
-
const lower = word.trim().toLocaleLowerCase("tr-TR");
|
|
1107
|
-
const variants: string[] = [];
|
|
1108
|
-
for (let i = 0; i < lower.length; i++) {
|
|
1109
|
-
for (const replacement of this.TURKISH_DEASCII_MAP[lower[i]] ?? []) {
|
|
1110
|
-
variants.push(lower.slice(0, i) + replacement + lower.slice(i + 1));
|
|
1111
|
-
}
|
|
1112
|
-
}
|
|
1113
|
-
return variants;
|
|
1114
|
-
}
|
|
1115
|
-
|
|
1116
|
-
/**
|
|
1117
|
-
* Returns Kubbealtı Lugatı ("Misalli Büyük Türkçe Sözlük") entries for a
|
|
1118
|
-
* word, scraped from the site's own data API — undocumented, and Kubbealtı
|
|
1119
|
-
* Lugatı is a commercial dictionary product, unlike TDK's or Wiktionary's
|
|
1120
|
-
* openly-published data, so use this in line with their terms. `anlam` is
|
|
1121
|
-
* raw HTML (rich typography markup); use `getKubbealtiMeanings()` for
|
|
1122
|
-
* plain text. Falls back to `generateTurkishVariants()` if the exact query
|
|
1123
|
-
* comes up empty (see its doc comment). Returns `null` on any fetch/parse
|
|
1124
|
-
* failure, `[]` if no variant matches either.
|
|
1125
|
-
*/
|
|
1126
|
-
public static async getKubbealti(word: string): Promise<KubbealtiEntry[] | null> {
|
|
1127
|
-
if (!word || word.trim() === "") return null;
|
|
1128
|
-
|
|
1129
|
-
const data = await this.fetchKubbealtiJson(`/rest/s/${encodeURIComponent(word.trim())}/`);
|
|
1130
|
-
if (!data || !Array.isArray(data.content)) return null;
|
|
1131
|
-
if (data.content.length > 0) {
|
|
1132
|
-
return data.content.map((entry: any) => ({ kelime: entry.kelime, anlam: entry.anlam }));
|
|
1133
|
-
}
|
|
1134
|
-
|
|
1135
|
-
for (const variant of this.generateTurkishVariants(word)) {
|
|
1136
|
-
const variantData = await this.fetchKubbealtiJson(`/rest/s/${encodeURIComponent(variant)}/`);
|
|
1137
|
-
if (variantData && Array.isArray(variantData.content) && variantData.content.length > 0) {
|
|
1138
|
-
return variantData.content.map((entry: any) => ({ kelime: entry.kelime, anlam: entry.anlam }));
|
|
1139
|
-
}
|
|
1140
|
-
}
|
|
1141
|
-
return [];
|
|
1142
|
-
}
|
|
1143
|
-
|
|
1144
|
-
/**
|
|
1145
|
-
* Same as `getKubbealti()` but with each entry's `anlam` HTML stripped to
|
|
1146
|
-
* plain text via `htmlToPlainText()`.
|
|
1147
|
-
*/
|
|
1148
|
-
public static async getKubbealtiMeanings(word: string): Promise<string[] | null> {
|
|
1149
|
-
const entries = await this.getKubbealti(word);
|
|
1150
|
-
if (!entries) return null;
|
|
1151
|
-
return entries.map((e) => this.htmlToPlainText(e.anlam));
|
|
1152
|
-
}
|
|
1153
|
-
|
|
1154
|
-
/**
|
|
1155
|
-
* Autocomplete suggestions from Kubbealtı Lugatı's own typeahead endpoint
|
|
1156
|
-
* (separate from `getSuggestions()`, which uses TDK's data).
|
|
1157
|
-
*/
|
|
1158
|
-
public static async getKubbealtiSuggestions(prefix: string): Promise<string[]> {
|
|
1159
|
-
if (!prefix || prefix.trim() === "") return [];
|
|
1160
|
-
const data = await this.fetchKubbealtiJson(`/rest/word-search/${encodeURIComponent(prefix.trim())}`);
|
|
1161
|
-
if (!Array.isArray(data)) return [];
|
|
1162
|
-
return data.map((item: any) => item.display).filter(Boolean);
|
|
1163
|
-
}
|
|
1164
|
-
|
|
1165
|
-
/**
|
|
1166
|
-
* Returns the etymology paragraph for a word from Nişanyan Sözlük, scraped
|
|
1167
|
-
* from that page's server-rendered `<meta name="description">` tag (the
|
|
1168
|
-
* page already puts the full etymology text there for SEO, so no need to
|
|
1169
|
-
* parse the site's internal SvelteKit data format). Returns `null` if the
|
|
1170
|
-
* word isn't found (the page falls back to a generic site tagline in that
|
|
1171
|
-
* case) or the request fails.
|
|
1172
|
-
*/
|
|
1173
|
-
public static async getNisanyan(word: string): Promise<string | null> {
|
|
1174
|
-
if (!word || word.trim() === "") return null;
|
|
1175
|
-
try {
|
|
1176
|
-
const response = await fetch(
|
|
1177
|
-
`https://www.nisanyansozluk.com/kelime/${encodeURIComponent(word.trim().toLocaleLowerCase("tr-TR"))}`,
|
|
1178
|
-
{ headers: { "User-Agent": "TDK-API-Nodejs-Wrapper/1.0" } }
|
|
1179
|
-
);
|
|
1180
|
-
if (!response.ok) return null;
|
|
1181
|
-
const html = await response.text();
|
|
1182
|
-
const match = html.match(/<meta name="description" content="([^"]*)"/);
|
|
1183
|
-
if (!match) return null;
|
|
1184
|
-
const description = this.htmlToPlainText(match[1]);
|
|
1185
|
-
if (description === "Çağdaş Türkçenin Etimolojisi") return null;
|
|
1186
|
-
return description;
|
|
1187
|
-
} catch {
|
|
1188
|
-
return null;
|
|
1189
|
-
}
|
|
1190
|
-
}
|
|
1191
|
-
|
|
1192
|
-
private static async fetchWiktionaryEntry(title: string): Promise<WiktionaryEntry | null> {
|
|
1193
|
-
try {
|
|
1194
|
-
const url = `https://tr.wiktionary.org/w/api.php?action=query&prop=extracts&titles=${encodeURIComponent(
|
|
1195
|
-
title
|
|
1196
|
-
)}&format=json&explaintext=1&formatversion=2`;
|
|
1197
|
-
const response = await fetch(url, { headers: { "User-Agent": "TDK-API-Nodejs-Wrapper/1.0" } });
|
|
1198
|
-
if (!response.ok) return null;
|
|
1199
|
-
const data = await response.json();
|
|
1200
|
-
const page = data?.query?.pages?.[0];
|
|
1201
|
-
if (!page || page.missing || !page.extract) return null;
|
|
1202
|
-
|
|
1203
|
-
const raw: string = page.extract;
|
|
1204
|
-
const sections: Record<string, string> = {};
|
|
1205
|
-
const parts = raw.split(/\n(={2,4})\s*(.+?)\s*\1\n/);
|
|
1206
|
-
// parts[0] is text before the first heading (usually empty); after
|
|
1207
|
-
// that, headings and their following text alternate in triples.
|
|
1208
|
-
for (let i = 1; i < parts.length; i += 3) {
|
|
1209
|
-
const title = parts[i + 1]?.trim();
|
|
1210
|
-
const content = parts[i + 2]?.trim();
|
|
1211
|
-
if (title) sections[title] = content ?? "";
|
|
1212
|
-
}
|
|
1213
|
-
return { raw, sections };
|
|
1214
|
-
} catch {
|
|
1215
|
-
return null;
|
|
1216
|
-
}
|
|
1217
|
-
}
|
|
1218
|
-
|
|
1219
|
-
/**
|
|
1220
|
-
* Returns the Turkish Wiktionary (`tr.wiktionary.org`) entry for a word,
|
|
1221
|
-
* via MediaWiki's official Action API (`action=query&prop=extracts`) — no
|
|
1222
|
-
* scraping involved, this is a stable, documented public API. `sections`
|
|
1223
|
-
* splits the plain-text extract on its `== Heading ==`/`=== Heading ===`
|
|
1224
|
-
* markers (e.g. "Köken", "Söyleniş", "Ad") for convenience; `raw` has the
|
|
1225
|
-
* unsplit text. This wiki has title capitalization turned off
|
|
1226
|
-
* ($wgCapitalLinks=false — common for Wiktionaries, since case is
|
|
1227
|
-
* meaningful for a dictionary: "Türkiye" the country vs. a lowercase
|
|
1228
|
-
* common word), so an exact-case miss retries with the first letter
|
|
1229
|
-
* uppercased (Turkish-locale-aware, so "istanbul" tries "İstanbul", not
|
|
1230
|
-
* "Istanbul") before giving up. Returns `null` if neither is found or the
|
|
1231
|
-
* request fails.
|
|
1232
|
-
*/
|
|
1233
|
-
public static async getWiktionary(word: string): Promise<WiktionaryEntry | null> {
|
|
1234
|
-
if (!word || word.trim() === "") return null;
|
|
1235
|
-
const trimmed = word.trim();
|
|
1236
|
-
|
|
1237
|
-
const direct = await this.fetchWiktionaryEntry(trimmed);
|
|
1238
|
-
if (direct) return direct;
|
|
1239
|
-
|
|
1240
|
-
const capitalized = trimmed.charAt(0).toLocaleUpperCase("tr-TR") + trimmed.slice(1);
|
|
1241
|
-
if (capitalized === trimmed) return null;
|
|
1242
|
-
return this.fetchWiktionaryEntry(capitalized);
|
|
1243
|
-
}
|
|
1244
|
-
|
|
1245
|
-
/**
|
|
1246
|
-
* Convenience filter over `getWiktionary()`: returns just one section's
|
|
1247
|
-
* text (e.g. `getWiktionarySection(word, "Köken")` for etymology), matched
|
|
1248
|
-
* case-insensitively. Returns `null` if the word or the section isn't found.
|
|
1249
|
-
*/
|
|
1250
|
-
public static async getWiktionarySection(word: string, sectionName: string): Promise<string | null> {
|
|
1251
|
-
const entry = await this.getWiktionary(word);
|
|
1252
|
-
if (!entry) return null;
|
|
1253
|
-
const key = Object.keys(entry.sections).find(
|
|
1254
|
-
(k) => k.toLocaleLowerCase("tr-TR") === sectionName.trim().toLocaleLowerCase("tr-TR")
|
|
1255
|
-
);
|
|
1256
|
-
return key ? entry.sections[key] : null;
|
|
1257
|
-
}
|
|
1258
|
-
|
|
1259
|
-
/**
|
|
1260
|
-
* Returns compound words that contain this word.
|
|
1261
|
-
*/
|
|
1262
|
-
public static async getCompoundWords(word: string): Promise<string[]> {
|
|
1263
|
-
const results = await this.getWord(word);
|
|
1264
|
-
if (results.length === 0) return [];
|
|
1265
|
-
|
|
1266
|
-
const compound: string[] = [];
|
|
1267
|
-
for (const result of results) {
|
|
1268
|
-
if (result.birlesikler) {
|
|
1269
|
-
const words = result.birlesikler.split(',').map(w => w.trim());
|
|
1270
|
-
compound.push(...words);
|
|
1271
|
-
}
|
|
1272
|
-
}
|
|
1273
|
-
return [...new Set(compound)];
|
|
1274
|
-
}
|
|
1275
|
-
|
|
1276
|
-
/**
|
|
1277
|
-
* Returns the part of speech (isim, sıfat, zarf vb.).
|
|
1278
|
-
* TDK's `ozelliklerListe` mixes grammatical categories (`tur: "3"`, e.g.
|
|
1279
|
-
* sıfat/zarf/isim) with usage-register tags (`tur: "4"`, e.g. mecaz/argo)
|
|
1280
|
-
* in the same list — only `tur === "3"` entries are actual parts of speech.
|
|
1281
|
-
*/
|
|
1282
|
-
public static async getPartOfSpeech(word: string): Promise<string[]> {
|
|
1283
|
-
const results = await this.getWord(word);
|
|
1284
|
-
const pos = new Set<string>();
|
|
1285
|
-
|
|
1286
|
-
for (const result of results) {
|
|
1287
|
-
if (result.anlamlarListe) {
|
|
1288
|
-
for (const anlam of result.anlamlarListe) {
|
|
1289
|
-
if (anlam.ozelliklerListe) {
|
|
1290
|
-
for (const ozellik of anlam.ozelliklerListe) {
|
|
1291
|
-
if (ozellik.tur === "3") pos.add(ozellik.tam_adi);
|
|
1292
|
-
}
|
|
1293
|
-
}
|
|
1294
|
-
}
|
|
1295
|
-
}
|
|
1296
|
-
}
|
|
1297
|
-
if (pos.size === 0 && results.length > 0) {
|
|
1298
|
-
pos.add('isim'); // Default to noun if TDK doesn't specify
|
|
1299
|
-
}
|
|
1300
|
-
return Array.from(pos);
|
|
1301
|
-
}
|
|
1302
|
-
|
|
1303
|
-
/**
|
|
1304
|
-
* Compares two words side by side: meaning count, etymological origin,
|
|
1305
|
-
* syllables and vowel-harmony compliance.
|
|
1306
|
-
*/
|
|
1307
|
-
public static async compareWords(a: string, b: string): Promise<WordComparison> {
|
|
1308
|
-
const [meaningsA, meaningsB, originA, originB] = await Promise.all([
|
|
1309
|
-
this.getMeanings(a),
|
|
1310
|
-
this.getMeanings(b),
|
|
1311
|
-
this.getOrigin(a),
|
|
1312
|
-
this.getOrigin(b),
|
|
1313
|
-
]);
|
|
1314
|
-
return {
|
|
1315
|
-
a: {
|
|
1316
|
-
word: a,
|
|
1317
|
-
meaningCount: meaningsA.length,
|
|
1318
|
-
origin: originA,
|
|
1319
|
-
syllables: this.syllabicate(a),
|
|
1320
|
-
harmony: this.checkVowelHarmony(a),
|
|
1321
|
-
labialHarmony: this.checkLabialHarmony(a),
|
|
1322
|
-
},
|
|
1323
|
-
b: {
|
|
1324
|
-
word: b,
|
|
1325
|
-
meaningCount: meaningsB.length,
|
|
1326
|
-
origin: originB,
|
|
1327
|
-
syllables: this.syllabicate(b),
|
|
1328
|
-
harmony: this.checkVowelHarmony(b),
|
|
1329
|
-
labialHarmony: this.checkLabialHarmony(b),
|
|
1330
|
-
},
|
|
1331
|
-
};
|
|
1332
|
-
}
|
|
1333
|
-
|
|
1334
|
-
private static readonly STOPWORDS = new Set([
|
|
1335
|
-
"ve", "veya", "ile", "ama", "fakat", "ancak", "de", "da", "ki", "bu", "şu", "o",
|
|
1336
|
-
"bir", "çok", "az", "gibi", "için", "mi", "mı", "mu", "mü", "ne", "her", "hiç",
|
|
1337
|
-
"ben", "sen", "biz", "siz", "onlar", "değil", "bile", "diye",
|
|
1338
|
-
]);
|
|
1339
|
-
|
|
1340
|
-
private static firstMeaning(results: WordInfo[]): string | null {
|
|
1341
|
-
for (const result of results) {
|
|
1342
|
-
for (const anlam of result.anlamlarListe ?? []) {
|
|
1343
|
-
if (anlam.anlam) return anlam.anlam;
|
|
1344
|
-
}
|
|
1345
|
-
}
|
|
1346
|
-
return null;
|
|
1347
|
-
}
|
|
1348
|
-
|
|
1349
|
-
/**
|
|
1350
|
-
* Analyzes every distinct word in a text (Turkish stopwords filtered out),
|
|
1351
|
-
* returning each word's first meaning and etymological origin if found.
|
|
1352
|
-
* Looks each word up individually (throttled), so scales with text length.
|
|
1353
|
-
* TDK only indexes dictionary (dictionary/root) forms, not inflected ones —
|
|
1354
|
-
* it does no morphological analysis, and neither does this method: a
|
|
1355
|
-
* suffixed word like "evde" or "dildir" (root "ev"/"dil" plus a case/verb
|
|
1356
|
-
* suffix) will come back `found: false` even though the root is a real
|
|
1357
|
-
* headword. This is an inherent limitation of the data source, not a bug.
|
|
1358
|
-
*/
|
|
1359
|
-
public static async analyzeText(text: string): Promise<WordAnalysis[]> {
|
|
1360
|
-
const words = text
|
|
1361
|
-
.toLocaleLowerCase("tr-TR")
|
|
1362
|
-
.replace(/[^\p{L}\s]/gu, " ")
|
|
1363
|
-
.split(/\s+/)
|
|
1364
|
-
.filter((w) => w.length > 1 && !this.STOPWORDS.has(w));
|
|
1365
|
-
const unique = [...new Set(words)];
|
|
1366
|
-
|
|
1367
|
-
const analyses: WordAnalysis[] = [];
|
|
1368
|
-
for (const word of unique) {
|
|
1369
|
-
let results = await this.getWord(word);
|
|
1370
|
-
let found = results.length > 0;
|
|
1371
|
-
let root: string | undefined;
|
|
1372
|
-
let isInflected: boolean | undefined;
|
|
1373
|
-
|
|
1374
|
-
if (!found) {
|
|
1375
|
-
const resolvedRoot = await this.findRoot(word);
|
|
1376
|
-
if (resolvedRoot) {
|
|
1377
|
-
results = await this.getWord(resolvedRoot);
|
|
1378
|
-
if (results.length > 0) {
|
|
1379
|
-
found = true;
|
|
1380
|
-
root = resolvedRoot;
|
|
1381
|
-
isInflected = true;
|
|
1382
|
-
}
|
|
1383
|
-
}
|
|
1384
|
-
}
|
|
1385
|
-
|
|
1386
|
-
analyses.push({
|
|
1387
|
-
word,
|
|
1388
|
-
found,
|
|
1389
|
-
meaning: found ? this.firstMeaning(results) : null,
|
|
1390
|
-
origin: found ? results[0].lisan || "Türkçe" : null,
|
|
1391
|
-
root,
|
|
1392
|
-
isInflected,
|
|
1393
|
-
});
|
|
1394
|
-
await this.delay(200);
|
|
1395
|
-
}
|
|
1396
|
-
return analyses;
|
|
1397
|
-
}
|
|
1398
|
-
|
|
1399
|
-
/**
|
|
1400
|
-
* Damerau-Levenshtein edit-distance (optimal string alignment variant):
|
|
1401
|
-
* like classic Levenshtein but also counts an adjacent-character
|
|
1402
|
-
* transposition (e.g. "yanlız" -> "yalnız") as a single edit instead of
|
|
1403
|
-
* two substitutions — a very common class of typo that plain Levenshtein
|
|
1404
|
-
* otherwise misses.
|
|
1405
|
-
*/
|
|
1406
|
-
private static damerauLevenshtein(a: string, b: string): number {
|
|
1407
|
-
const dp: number[][] = Array.from({ length: a.length + 1 }, () => new Array(b.length + 1).fill(0));
|
|
1408
|
-
for (let i = 0; i <= a.length; i++) dp[i][0] = i;
|
|
1409
|
-
for (let j = 0; j <= b.length; j++) dp[0][j] = j;
|
|
1410
|
-
for (let i = 1; i <= a.length; i++) {
|
|
1411
|
-
for (let j = 1; j <= b.length; j++) {
|
|
1412
|
-
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
1413
|
-
dp[i][j] = Math.min(dp[i - 1][j] + 1, dp[i][j - 1] + 1, dp[i - 1][j - 1] + cost);
|
|
1414
|
-
if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) {
|
|
1415
|
-
dp[i][j] = Math.min(dp[i][j], dp[i - 2][j - 2] + cost);
|
|
1416
|
-
}
|
|
1417
|
-
}
|
|
1418
|
-
}
|
|
1419
|
-
return dp[a.length][b.length];
|
|
1420
|
-
}
|
|
1421
|
-
|
|
1422
|
-
/**
|
|
1423
|
-
* Fetches multiple words concurrently with a small delay to avoid rate limiting.
|
|
1424
|
-
*/
|
|
1425
|
-
public static async getWordsBatch(words: string[]): Promise<WordInfo[][]> {
|
|
1426
|
-
const results: WordInfo[][] = [];
|
|
1427
|
-
for (const word of words) {
|
|
1428
|
-
try {
|
|
1429
|
-
const res = await this.getWord(word);
|
|
1430
|
-
results.push(res);
|
|
1431
|
-
} catch {
|
|
1432
|
-
results.push([]);
|
|
1433
|
-
}
|
|
1434
|
-
await this.delay(200); // 200ms throttle
|
|
1435
|
-
}
|
|
1436
|
-
return results;
|
|
1437
|
-
}
|
|
1438
|
-
|
|
1439
|
-
/**
|
|
1440
|
-
* Syllabicates a Turkish word based on general grammar rules.
|
|
1441
|
-
* Handles syllable separation for vowels, single consonants, double consonants,
|
|
1442
|
-
* and western loanword three-consonant clusters (e.g. e-lek-trik, kon-trol, or-kes-tra).
|
|
1443
|
-
*/
|
|
1444
|
-
public static syllabicate(word: string): string[] {
|
|
1445
|
-
const vowels = /[aeıioöuüAEIİOÖUÜ]/;
|
|
1446
|
-
const ONSET_CLUSTERS = new Set(["tr", "pr", "kr", "gr", "br", "fr", "dr", "pl", "kl", "fl", "bl", "gl"]);
|
|
1447
|
-
const result: string[] = [];
|
|
1448
|
-
let currentSyllable = "";
|
|
1449
|
-
|
|
1450
|
-
// Go from right to left.
|
|
1451
|
-
for (let i = word.length - 1; i >= 0; i--) {
|
|
1452
|
-
currentSyllable = word[i] + currentSyllable;
|
|
1453
|
-
if (vowels.test(word[i])) {
|
|
1454
|
-
// If the preceding char is a consonant and it's not the first char
|
|
1455
|
-
// and the char before that is a vowel, then the consonant belongs to this syllable.
|
|
1456
|
-
if (i - 1 >= 0 && !vowels.test(word[i - 1])) {
|
|
1457
|
-
// It's a consonant.
|
|
1458
|
-
if (i - 2 >= 0 && vowels.test(word[i - 2])) {
|
|
1459
|
-
currentSyllable = word[i - 1] + currentSyllable;
|
|
1460
|
-
i--; // skip the consonant
|
|
1461
|
-
} else if (i - 2 >= 0 && !vowels.test(word[i - 2])) {
|
|
1462
|
-
// Two consonants before this vowel. Check if three consonants exist and end in an onset cluster
|
|
1463
|
-
if (i - 3 >= 0 && !vowels.test(word[i - 3]) && ONSET_CLUSTERS.has((word[i - 2] + word[i - 1]).toLowerCase())) {
|
|
1464
|
-
currentSyllable = word[i - 2] + word[i - 1] + currentSyllable;
|
|
1465
|
-
i -= 2;
|
|
1466
|
-
} else {
|
|
1467
|
-
currentSyllable = word[i - 1] + currentSyllable;
|
|
1468
|
-
i--;
|
|
1469
|
-
}
|
|
1470
|
-
}
|
|
1471
|
-
}
|
|
1472
|
-
result.unshift(currentSyllable);
|
|
1473
|
-
currentSyllable = "";
|
|
1474
|
-
}
|
|
1475
|
-
}
|
|
1476
|
-
// If there is anything left (e.g. no vowels at the start like "tr"), add it to the first syllable
|
|
1477
|
-
if (currentSyllable) {
|
|
1478
|
-
if (result.length > 0) {
|
|
1479
|
-
result[0] = currentSyllable + result[0];
|
|
1480
|
-
} else {
|
|
1481
|
-
result.push(currentSyllable);
|
|
1482
|
-
}
|
|
1483
|
-
}
|
|
1484
|
-
return result;
|
|
1485
|
-
}
|
|
1486
|
-
|
|
1487
|
-
/**
|
|
1488
|
-
* Checks if a word follows Turkish Major Vowel Harmony (Büyük Ünlü Uyumu).
|
|
1489
|
-
* Normalizes case via the Turkish locale first: a plain case-insensitive
|
|
1490
|
-
* regex would fold ASCII "I" to "i", misreading the back vowel "I"
|
|
1491
|
-
* (dotless) as the front vowel "i" (dotted).
|
|
1492
|
-
*/
|
|
1493
|
-
public static checkVowelHarmony(word: string): boolean {
|
|
1494
|
-
const lower = word.toLocaleLowerCase("tr-TR");
|
|
1495
|
-
const backVowels = /[aıou]/;
|
|
1496
|
-
const frontVowels = /[eiöü]/;
|
|
1497
|
-
const hasBack = backVowels.test(lower);
|
|
1498
|
-
const hasFront = frontVowels.test(lower);
|
|
1499
|
-
|
|
1500
|
-
// If it has both front and back vowels, it breaks harmony.
|
|
1501
|
-
return !(hasBack && hasFront);
|
|
1502
|
-
}
|
|
1503
|
-
|
|
1504
|
-
/**
|
|
1505
|
-
* Checks if a word follows Turkish Minor Vowel Harmony (Küçük Ünlü Uyumu / Labial Harmony).
|
|
1506
|
-
* Rules:
|
|
1507
|
-
* 1. After an unrounded vowel (a, e, ı, i), only unrounded vowels (a, e, ı, i) can follow.
|
|
1508
|
-
* 2. After a rounded vowel (o, ö, u, ü), either an unrounded wide (a, e) or rounded narrow (u, ü) vowel can follow.
|
|
1509
|
-
* Single-syllable words and words with <=1 vowel are considered compliant by convention.
|
|
1510
|
-
*/
|
|
1511
|
-
public static checkLabialHarmony(word: string): boolean {
|
|
1512
|
-
const lower = word.toLocaleLowerCase("tr-TR");
|
|
1513
|
-
const vowels = lower.split("").filter((ch) => "aeıioöuü".includes(ch));
|
|
1514
|
-
if (vowels.length <= 1) return true;
|
|
1515
|
-
|
|
1516
|
-
for (let i = 0; i < vowels.length - 1; i++) {
|
|
1517
|
-
const v1 = vowels[i];
|
|
1518
|
-
const v2 = vowels[i + 1];
|
|
1519
|
-
|
|
1520
|
-
if ("aeıi".includes(v1)) {
|
|
1521
|
-
if (!"aeıi".includes(v2)) return false;
|
|
1522
|
-
} else if ("oöuü".includes(v1)) {
|
|
1523
|
-
if (!"aeuü".includes(v2)) return false;
|
|
1524
|
-
}
|
|
1525
|
-
}
|
|
1526
|
-
return true;
|
|
1527
|
-
}
|
|
1528
|
-
|
|
1529
|
-
/**
|
|
1530
|
-
* Searches TDK headwords using a wildcard / pattern string.
|
|
1531
|
-
* Wildcards:
|
|
1532
|
-
* '_' or '?' matches any single character
|
|
1533
|
-
* '*' matches zero or more characters
|
|
1534
|
-
* Example: "k_l_m" matches "kalem", "kelam", "kilim".
|
|
1535
|
-
* Runs in-memory against TDK's 81k headword list.
|
|
1536
|
-
*/
|
|
1537
|
-
public static async patternSearch(pattern: string, options?: PatternSearchOptions): Promise<string[]> {
|
|
1538
|
-
if (!pattern || pattern.trim() === "") return [];
|
|
1539
|
-
await this.ensureAutocompleteLoaded();
|
|
1540
|
-
|
|
1541
|
-
const cleanPattern = pattern.trim().toLocaleLowerCase("tr-TR");
|
|
1542
|
-
const escaped = cleanPattern
|
|
1543
|
-
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
|
|
1544
|
-
.replace(/[_?]/g, "[\\p{L}]")
|
|
1545
|
-
.replace(/\*/g, "[\\p{L}]*");
|
|
1546
|
-
const regex = new RegExp(`^${escaped}$`, "u");
|
|
1547
|
-
|
|
1548
|
-
const max = options?.maxResults ?? 50;
|
|
1549
|
-
const matches: string[] = [];
|
|
1550
|
-
|
|
1551
|
-
for (const headword of this.autocompleteCache) {
|
|
1552
|
-
const lower = headword.toLocaleLowerCase("tr-TR");
|
|
1553
|
-
if (regex.test(lower)) {
|
|
1554
|
-
matches.push(headword);
|
|
1555
|
-
if (matches.length >= max) break;
|
|
1556
|
-
}
|
|
1557
|
-
}
|
|
1558
|
-
return matches;
|
|
1559
|
-
}
|
|
1560
|
-
|
|
1561
|
-
/**
|
|
1562
|
-
* Finds headwords in TDK that can be formed from the given letters (anagrams).
|
|
1563
|
-
* If exact-length anagrams exist, they are returned.
|
|
1564
|
-
* If none exist (or exactLength is false), valid sub-anagrams (words using a subset of the letters,
|
|
1565
|
-
* minimum 3 letters) are returned, sorted by length descending.
|
|
1566
|
-
*/
|
|
1567
|
-
public static async findAnagrams(letters: string, options?: AnagramOptions): Promise<string[]> {
|
|
1568
|
-
if (!letters || letters.trim() === "") return [];
|
|
1569
|
-
await this.ensureAutocompleteLoaded();
|
|
1570
|
-
|
|
1571
|
-
const clean = letters.trim().toLocaleLowerCase("tr-TR").replace(/[^a-zçğıöşüâîû]/gi, "");
|
|
1572
|
-
if (clean.length === 0) return [];
|
|
1573
|
-
|
|
1574
|
-
const forceExact = options?.exactLength === true;
|
|
1575
|
-
const max = options?.maxResults ?? 50;
|
|
1576
|
-
|
|
1577
|
-
const getFrequency = (str: string): Record<string, number> => {
|
|
1578
|
-
const freq: Record<string, number> = {};
|
|
1579
|
-
for (const ch of str) {
|
|
1580
|
-
freq[ch] = (freq[ch] || 0) + 1;
|
|
1581
|
-
}
|
|
1582
|
-
return freq;
|
|
1583
|
-
};
|
|
1584
|
-
|
|
1585
|
-
const targetFreq = getFrequency(clean);
|
|
1586
|
-
const exactMatches: string[] = [];
|
|
1587
|
-
const subMatches: string[] = [];
|
|
1588
|
-
|
|
1589
|
-
for (const headword of this.autocompleteCache) {
|
|
1590
|
-
const lower = headword.toLocaleLowerCase("tr-TR");
|
|
1591
|
-
if (lower.includes(" ") || lower.includes("-")) continue;
|
|
1592
|
-
if (lower.length > clean.length || lower.length < 3) continue;
|
|
1593
|
-
|
|
1594
|
-
const wordFreq = getFrequency(lower);
|
|
1595
|
-
let isValid = true;
|
|
1596
|
-
for (const [ch, count] of Object.entries(wordFreq)) {
|
|
1597
|
-
if (!targetFreq[ch] || targetFreq[ch] < count) {
|
|
1598
|
-
isValid = false;
|
|
1599
|
-
break;
|
|
1600
|
-
}
|
|
1601
|
-
}
|
|
1602
|
-
|
|
1603
|
-
if (isValid && lower !== clean) {
|
|
1604
|
-
if (lower.length === clean.length) {
|
|
1605
|
-
exactMatches.push(headword);
|
|
1606
|
-
} else {
|
|
1607
|
-
subMatches.push(headword);
|
|
1608
|
-
}
|
|
1609
|
-
}
|
|
1610
|
-
}
|
|
1611
|
-
|
|
1612
|
-
if (exactMatches.length > 0 || forceExact) {
|
|
1613
|
-
return exactMatches.slice(0, max);
|
|
1614
|
-
}
|
|
1615
|
-
|
|
1616
|
-
subMatches.sort((a, b) => b.length - a.length || a.localeCompare(b, "tr-TR"));
|
|
1617
|
-
return subMatches.slice(0, max);
|
|
1618
|
-
}
|
|
1619
|
-
|
|
1620
|
-
/**
|
|
1621
|
-
* Finds words in TDK that rhyme with the given word (sharing the same ending suffix/letters).
|
|
1622
|
-
* @param word The target word
|
|
1623
|
-
* @param options.minLetters Minimum number of ending characters that must match (default: 3)
|
|
1624
|
-
* @param options.maxResults Maximum number of rhyme results to return (default: 50)
|
|
1625
|
-
*/
|
|
1626
|
-
public static async findRhymes(word: string, options?: RhymeOptions): Promise<string[]> {
|
|
1627
|
-
if (!word || word.trim() === "") return [];
|
|
1628
|
-
await this.ensureAutocompleteLoaded();
|
|
1629
|
-
|
|
1630
|
-
const clean = word.trim().toLocaleLowerCase("tr-TR");
|
|
1631
|
-
const minLetters = Math.min(options?.minLetters ?? 3, clean.length);
|
|
1632
|
-
const max = options?.maxResults ?? 50;
|
|
1633
|
-
|
|
1634
|
-
const suffix = clean.slice(-minLetters);
|
|
1635
|
-
const results: string[] = [];
|
|
1636
|
-
|
|
1637
|
-
for (const headword of this.autocompleteCache) {
|
|
1638
|
-
const lower = headword.toLocaleLowerCase("tr-TR");
|
|
1639
|
-
if (lower !== clean && lower.endsWith(suffix) && !lower.includes(" ")) {
|
|
1640
|
-
results.push(headword);
|
|
1641
|
-
if (results.length >= max) break;
|
|
1642
|
-
}
|
|
1643
|
-
}
|
|
1644
|
-
|
|
1645
|
-
return results;
|
|
1646
|
-
}
|
|
1647
|
-
|
|
1648
|
-
/**
|
|
1649
|
-
* Performs comprehensive spelling, grammar, and syntax proofreading on a Turkish text.
|
|
1650
|
-
* Detects:
|
|
1651
|
-
* 1. Conjunction 'da/de' erroneously joined to verbs or words (e.g. "gitsende" -> "gitsen de")
|
|
1652
|
-
* 2. Conjunction 'ki' erroneously joined to verbs (e.g. "gördümki" -> "gördüm ki"), respecting SOMBAHÇEMİ exceptions
|
|
1653
|
-
* 3. Question particle 'mi/mı/mu/mü' erroneously joined to words (e.g. "geldimi" -> "geldi mi")
|
|
1654
|
-
* 4. Misspelled words with dictionary suggestions (via edit-distance & morphology)
|
|
1655
|
-
*/
|
|
1656
|
-
public static async proofread(text: string): Promise<ProofreadResult> {
|
|
1657
|
-
if (!text || text.trim() === "") {
|
|
1658
|
-
return { text: text || "", issues: [], isCorrect: true };
|
|
1659
|
-
}
|
|
1660
|
-
|
|
1661
|
-
await this.ensureAutocompleteLoaded();
|
|
1662
|
-
const issues: ProofreadIssue[] = [];
|
|
1663
|
-
|
|
1664
|
-
const SOMBAHCEMI = new Set([
|
|
1665
|
-
"sanki", "oysaki", "mademki", "belki", "halbuki", "çünkü", "meğerki", "illaki"
|
|
1666
|
-
]);
|
|
1667
|
-
|
|
1668
|
-
// 1. Detect multi-word phrases that should be written as single compound words
|
|
1669
|
-
const PHRASE_MISTAKES: {
|
|
1670
|
-
regex: RegExp;
|
|
1671
|
-
suggestion: string;
|
|
1672
|
-
message: string;
|
|
1673
|
-
type: ProofreadIssue["type"];
|
|
1674
|
-
}[] = [
|
|
1675
|
-
{
|
|
1676
|
-
regex: /\bhiç\s+bir\b/gi,
|
|
1677
|
-
suggestion: "hiçbir",
|
|
1678
|
-
message: "'hiçbir' belgisiz sıfatı bitişik yazılmalıdır.",
|
|
1679
|
-
type: "spelling",
|
|
1680
|
-
},
|
|
1681
|
-
{
|
|
1682
|
-
regex: /\bbir\s+çok\b/gi,
|
|
1683
|
-
suggestion: "birçok",
|
|
1684
|
-
message: "'birçok' belgisiz sıfatı/zamiri bitişik yazılmalıdır.",
|
|
1685
|
-
type: "spelling",
|
|
1686
|
-
},
|
|
1687
|
-
{
|
|
1688
|
-
regex: /\bbir\s+kaç\b/gi,
|
|
1689
|
-
suggestion: "birkaç",
|
|
1690
|
-
message: "'birkaç' belgisiz sıfatı/zamiri bitişik yazılmalıdır.",
|
|
1691
|
-
type: "spelling",
|
|
1692
|
-
},
|
|
1693
|
-
{
|
|
1694
|
-
regex: /\bbir\s+az\b/gi,
|
|
1695
|
-
suggestion: "biraz",
|
|
1696
|
-
message: "'biraz' sözcüğü bitişik yazılmalıdır.",
|
|
1697
|
-
type: "spelling",
|
|
1698
|
-
},
|
|
1699
|
-
{
|
|
1700
|
-
regex: /\bher\s+hangi\b/gi,
|
|
1701
|
-
suggestion: "herhangi",
|
|
1702
|
-
message: "'herhangi' sözcüğü bitişik yazılmalıdır.",
|
|
1703
|
-
type: "spelling",
|
|
1704
|
-
},
|
|
1705
|
-
{
|
|
1706
|
-
regex: /\bgit\s+gide\b/gi,
|
|
1707
|
-
suggestion: "gitgide",
|
|
1708
|
-
message: "'gitgide' zarfı bitişik yazılmalıdır.",
|
|
1709
|
-
type: "spelling",
|
|
1710
|
-
},
|
|
1711
|
-
{
|
|
1712
|
-
regex: /\bbirden\s+bire\b/gi,
|
|
1713
|
-
suggestion: "birdenbire",
|
|
1714
|
-
message: "'birdenbire' zarfı bitişik yazılmalıdır.",
|
|
1715
|
-
type: "spelling",
|
|
1716
|
-
},
|
|
1717
|
-
{
|
|
1718
|
-
regex: /\brast\s+gele\b/gi,
|
|
1719
|
-
suggestion: "rastgele",
|
|
1720
|
-
message: "'rastgele' zarfı bitişik yazılmalıdır.",
|
|
1721
|
-
type: "spelling",
|
|
1722
|
-
},
|
|
1723
|
-
];
|
|
1724
|
-
|
|
1725
|
-
const coveredRanges: { start: number; end: number }[] = [];
|
|
1726
|
-
for (const pm of PHRASE_MISTAKES) {
|
|
1727
|
-
let pmMatch: RegExpExecArray | null;
|
|
1728
|
-
while ((pmMatch = pm.regex.exec(text)) !== null) {
|
|
1729
|
-
const start = pmMatch.index;
|
|
1730
|
-
const end = start + pmMatch[0].length;
|
|
1731
|
-
coveredRanges.push({ start, end });
|
|
1732
|
-
issues.push({
|
|
1733
|
-
type: pm.type,
|
|
1734
|
-
word: pmMatch[0],
|
|
1735
|
-
startIndex: start,
|
|
1736
|
-
endIndex: end,
|
|
1737
|
-
suggestion: pm.suggestion,
|
|
1738
|
-
message: pm.message,
|
|
1739
|
-
});
|
|
1740
|
-
}
|
|
1741
|
-
}
|
|
1742
|
-
|
|
1743
|
-
const tokenRegex = /[\p{L}0-9'’]+/gu;
|
|
1744
|
-
let match: RegExpExecArray | null;
|
|
1745
|
-
|
|
1746
|
-
while ((match = tokenRegex.exec(text)) !== null) {
|
|
1747
|
-
const rawWord = match[0];
|
|
1748
|
-
const startIndex = match.index;
|
|
1749
|
-
const endIndex = startIndex + rawWord.length;
|
|
1750
|
-
const lower = rawWord.toLocaleLowerCase("tr-TR");
|
|
1751
|
-
|
|
1752
|
-
if (/^\d+$/.test(lower)) continue;
|
|
1753
|
-
if (coveredRanges.some((r) => startIndex >= r.start && endIndex <= r.end)) continue;
|
|
1754
|
-
|
|
1755
|
-
let flagged = false;
|
|
1756
|
-
|
|
1757
|
-
// 1. Check Question Particle (mı, mi, mu, mü) erroneously attached
|
|
1758
|
-
const questionMatch = lower.match(/^(.+?)(m[ıiuü](?:sin|sın|sun|sün|siniz|sınız|sunuz|sünüz|yiz|yız|yuz|yüz|m|k)?)$/);
|
|
1759
|
-
if (questionMatch) {
|
|
1760
|
-
const base = questionMatch[1];
|
|
1761
|
-
const particle = questionMatch[2];
|
|
1762
|
-
if (base.length >= 2 && (await this.isHeadword(base) || (await this.findRoot(base)) !== null)) {
|
|
1763
|
-
if (!(await this.isHeadword(lower))) {
|
|
1764
|
-
issues.push({
|
|
1765
|
-
type: "question_particle",
|
|
1766
|
-
word: rawWord,
|
|
1767
|
-
startIndex,
|
|
1768
|
-
endIndex,
|
|
1769
|
-
suggestion: `${base} ${particle}`,
|
|
1770
|
-
message: `'${particle}' soru eki kendinden önceki kelimeden ayrı yazılmalıdır.`,
|
|
1771
|
-
});
|
|
1772
|
-
flagged = true;
|
|
1773
|
-
}
|
|
1774
|
-
}
|
|
1775
|
-
}
|
|
1776
|
-
|
|
1777
|
-
const VERB_CONJUGATION_REGEX =
|
|
1778
|
-
/(?:d[ıiuü][kmmn]?|t[ıiuü][kmmn]?|d[ıiuü]n[ıiuü]z?|t[ıiuü]n[ıiuü]z?|m[ıiuü]ş(?:[szn][ıiuü]z?|lar)?|yor(?:um|sun|uz|lar)?|ecek(?:sin|iz|ler)?|acak(?:sın|ız|lar)?|s[ae][mnk]|s[ae]n[ıiz]?|meli|malı|me[mz]|ma[mz])$/i;
|
|
1779
|
-
|
|
1780
|
-
// 2. Check Conjunction 'ki' erroneously attached to verbs
|
|
1781
|
-
if (!flagged && lower.endsWith("ki") && lower.length > 3) {
|
|
1782
|
-
const base = lower.slice(0, -2);
|
|
1783
|
-
if (!SOMBAHCEMI.has(lower)) {
|
|
1784
|
-
if (!(await this.isHeadword(lower))) {
|
|
1785
|
-
const root = await this.findRoot(base);
|
|
1786
|
-
const isVerb =
|
|
1787
|
-
(base === "demek" || base === "kaldı" || base === "yeter" || base === "bilmem" || VERB_CONJUGATION_REGEX.test(base)) &&
|
|
1788
|
-
(root ? root.endsWith("mek") || root.endsWith("mak") : true);
|
|
1789
|
-
|
|
1790
|
-
if (isVerb) {
|
|
1791
|
-
issues.push({
|
|
1792
|
-
type: "conjunction_ki",
|
|
1793
|
-
word: rawWord,
|
|
1794
|
-
startIndex,
|
|
1795
|
-
endIndex,
|
|
1796
|
-
suggestion: `${base} ki`,
|
|
1797
|
-
message: `'ki' bağlacı ayrı yazılmalıdır.`,
|
|
1798
|
-
});
|
|
1799
|
-
flagged = true;
|
|
1800
|
-
}
|
|
1801
|
-
}
|
|
1802
|
-
}
|
|
1803
|
-
}
|
|
1804
|
-
|
|
1805
|
-
// 3. Check Conjunction 'da/de/ta/te' erroneously attached to verbs
|
|
1806
|
-
if (!flagged && (lower.endsWith("de") || lower.endsWith("da") || lower.endsWith("te") || lower.endsWith("ta")) && lower.length > 3) {
|
|
1807
|
-
const base = lower.slice(0, -2);
|
|
1808
|
-
const ending = lower.slice(-2);
|
|
1809
|
-
if (!(await this.isHeadword(lower))) {
|
|
1810
|
-
const root = await this.findRoot(base);
|
|
1811
|
-
const isVerb =
|
|
1812
|
-
VERB_CONJUGATION_REGEX.test(base) &&
|
|
1813
|
-
(root ? root.endsWith("mek") || root.endsWith("mak") : false);
|
|
1814
|
-
|
|
1815
|
-
if (isVerb) {
|
|
1816
|
-
const correctEnding = ending.startsWith("t") ? (ending === "te" ? "de" : "da") : ending;
|
|
1817
|
-
issues.push({
|
|
1818
|
-
type: "conjunction_da",
|
|
1819
|
-
word: rawWord,
|
|
1820
|
-
startIndex,
|
|
1821
|
-
endIndex,
|
|
1822
|
-
suggestion: `${base} ${correctEnding}`,
|
|
1823
|
-
message: `'da/de' bağlacı fiillerden sonra her zaman ayrı yazılır (bağlaç olan da/de sertleşmez).`,
|
|
1824
|
-
});
|
|
1825
|
-
flagged = true;
|
|
1826
|
-
}
|
|
1827
|
-
}
|
|
1828
|
-
}
|
|
1829
|
-
|
|
1830
|
-
// 4. Check -şey / -sey erroneously attached to preceding word
|
|
1831
|
-
const seyMatch = lower.match(/^(.+?)(?:şey|sey)([ıiuaeüodekmnl]+)?$/);
|
|
1832
|
-
if (!flagged && seyMatch && !SEY_EXCEPTIONS.has(lower)) {
|
|
1833
|
-
let prefix = seyMatch[1];
|
|
1834
|
-
const suffix = seyMatch[2] || "";
|
|
1835
|
-
if (prefix === "hicbir") prefix = "hiçbir";
|
|
1836
|
-
if (prefix === "cok") prefix = "çok";
|
|
1837
|
-
issues.push({
|
|
1838
|
-
type: "spelling",
|
|
1839
|
-
word: rawWord,
|
|
1840
|
-
startIndex,
|
|
1841
|
-
endIndex,
|
|
1842
|
-
suggestion: `${prefix} şey${suffix}`,
|
|
1843
|
-
message: "'şey' sözcüğü kendinden önceki kelimeden ayrı yazılmalıdır.",
|
|
1844
|
-
});
|
|
1845
|
-
flagged = true;
|
|
1846
|
-
}
|
|
1847
|
-
|
|
1848
|
-
// 5. Check 'yada' conjunction mistake
|
|
1849
|
-
if (!flagged && lower === "yada") {
|
|
1850
|
-
issues.push({
|
|
1851
|
-
type: "spelling",
|
|
1852
|
-
word: rawWord,
|
|
1853
|
-
startIndex,
|
|
1854
|
-
endIndex,
|
|
1855
|
-
suggestion: "ya da",
|
|
1856
|
-
message: "'ya da' bağlacı her zaman ayrı yazılır.",
|
|
1857
|
-
});
|
|
1858
|
-
flagged = true;
|
|
1859
|
-
}
|
|
1860
|
-
|
|
1861
|
-
// 6. Check common vowel drop mistakes: burda, şurda, orda, vb. (TDK Kural 15)
|
|
1862
|
-
if (!flagged && (lower === "burda" || lower === "şurda" || lower === "surda" || lower === "orda" || lower === "içerde" || lower === "icerde" || lower === "dışarda" || lower === "disarda" || lower === "yukarda")) {
|
|
1863
|
-
const correct = COMMON_MISSPELLINGS[lower] || lower;
|
|
1864
|
-
issues.push({
|
|
1865
|
-
type: "spelling",
|
|
1866
|
-
word: rawWord,
|
|
1867
|
-
startIndex,
|
|
1868
|
-
endIndex,
|
|
1869
|
-
suggestion: correct,
|
|
1870
|
-
message: `'${rawWord}' sözcüğünde ünlü düşmesi yapılmaz.`,
|
|
1871
|
-
});
|
|
1872
|
-
flagged = true;
|
|
1873
|
-
}
|
|
1874
|
-
|
|
1875
|
-
// 7. General Spell Check
|
|
1876
|
-
if (!flagged) {
|
|
1877
|
-
const check = await this.checkSpelling(rawWord);
|
|
1878
|
-
if (!check.isCorrect) {
|
|
1879
|
-
issues.push({
|
|
1880
|
-
type: "spelling",
|
|
1881
|
-
word: rawWord,
|
|
1882
|
-
startIndex,
|
|
1883
|
-
endIndex,
|
|
1884
|
-
suggestion: check.suggestion,
|
|
1885
|
-
message: check.suggestion
|
|
1886
|
-
? `'${rawWord}' yanlış yazılmış olabilir.`
|
|
1887
|
-
: `'${rawWord}' sözlükte bulunamadı.`,
|
|
1888
|
-
});
|
|
1889
|
-
}
|
|
1890
|
-
}
|
|
1891
|
-
}
|
|
1892
|
-
|
|
1893
|
-
issues.sort((a, b) => a.startIndex - b.startIndex);
|
|
1894
|
-
|
|
1895
|
-
return {
|
|
1896
|
-
text,
|
|
1897
|
-
issues,
|
|
1898
|
-
isCorrect: issues.length === 0,
|
|
1899
|
-
};
|
|
1900
|
-
}
|
|
1901
|
-
}
|
|
1902
|
-
|
|
1903
|
-
/**
|
|
1904
|
-
* Configurable instance-based client for TDK API.
|
|
1905
|
-
* Useful for multi-tenant applications or backend services requiring isolated configurations.
|
|
1906
|
-
*/
|
|
1907
|
-
export class TDKClient {
|
|
1908
|
-
constructor(config?: TDKConfig) {
|
|
1909
|
-
if (config) {
|
|
1910
|
-
TDK.configure(config);
|
|
1911
|
-
}
|
|
1912
|
-
}
|
|
1913
|
-
|
|
1914
|
-
public getWord(word: string): Promise<WordInfo[]> {
|
|
1915
|
-
return TDK.getWord(word);
|
|
1916
|
-
}
|
|
1917
|
-
|
|
1918
|
-
public getMeanings(word: string): Promise<string[]> {
|
|
1919
|
-
return TDK.getMeanings(word);
|
|
1920
|
-
}
|
|
1921
|
-
|
|
1922
|
-
public checkSpelling(word: string): Promise<SpellCheckResult> {
|
|
1923
|
-
return TDK.checkSpelling(word);
|
|
1924
|
-
}
|
|
1925
|
-
|
|
1926
|
-
public findRoot(word: string): Promise<string | null> {
|
|
1927
|
-
return TDK.findRoot(word);
|
|
1928
|
-
}
|
|
1929
|
-
|
|
1930
|
-
public stem(word: string): Promise<StemResult | null> {
|
|
1931
|
-
return TDK.stem(word);
|
|
1932
|
-
}
|
|
1933
|
-
|
|
1934
|
-
public proofread(text: string): Promise<ProofreadResult> {
|
|
1935
|
-
return TDK.proofread(text);
|
|
1936
|
-
}
|
|
1937
|
-
|
|
1938
|
-
public patternSearch(pattern: string, options?: PatternSearchOptions): Promise<string[]> {
|
|
1939
|
-
return TDK.patternSearch(pattern, options);
|
|
1940
|
-
}
|
|
1941
|
-
|
|
1942
|
-
public findAnagrams(letters: string, options?: AnagramOptions): Promise<string[]> {
|
|
1943
|
-
return TDK.findAnagrams(letters, options);
|
|
1944
|
-
}
|
|
1945
|
-
|
|
1946
|
-
public findRhymes(word: string, options?: RhymeOptions): Promise<string[]> {
|
|
1947
|
-
return TDK.findRhymes(word, options);
|
|
1948
|
-
}
|
|
1949
|
-
|
|
1950
|
-
public syllabicate(word: string): string[] {
|
|
1951
|
-
return TDK.syllabicate(word);
|
|
1952
|
-
}
|
|
1953
|
-
|
|
1954
|
-
public checkVowelHarmony(word: string): boolean {
|
|
1955
|
-
return TDK.checkVowelHarmony(word);
|
|
1956
|
-
}
|
|
1957
|
-
|
|
1958
|
-
public checkLabialHarmony(word: string): boolean {
|
|
1959
|
-
return TDK.checkLabialHarmony(word);
|
|
1960
|
-
}
|
|
1961
|
-
}
|
|
1962
|
-
|