tdk-api-wrapper 1.3.1 → 1.5.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/README.md +52 -15
- package/dist/{chunk-ACMGCL7T.mjs → chunk-7KJHYRJZ.mjs} +1041 -16
- package/dist/cli.js +1205 -21
- package/dist/cli.mjs +219 -7
- package/dist/index.d.mts +206 -1
- package/dist/index.d.ts +206 -1
- package/dist/index.js +1052 -17
- package/dist/index.mjs +23 -3
- package/package.json +3 -2
- package/src/cli.ts +224 -6
- package/src/index.ts +2 -1
- package/src/morphology.ts +324 -0
- package/src/tdk.ts +560 -15
- package/src/types.ts +49 -0
- package/test/grammar.test.js +52 -0
- package/test/morphology.test.js +129 -0
- package/test/proofread.test.js +60 -0
- package/test/tools.test.js +51 -0
package/src/tdk.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type {
|
|
|
2
2
|
WordInfo,
|
|
3
3
|
DailyContent,
|
|
4
4
|
SpellCheckResult,
|
|
5
|
+
StemResult,
|
|
5
6
|
WordOfTheDay,
|
|
6
7
|
DailyPick,
|
|
7
8
|
WordComparison,
|
|
@@ -9,8 +10,15 @@ import type {
|
|
|
9
10
|
TDKRule,
|
|
10
11
|
KubbealtiEntry,
|
|
11
12
|
WiktionaryEntry,
|
|
13
|
+
ProofreadIssue,
|
|
14
|
+
ProofreadResult,
|
|
15
|
+
PatternSearchOptions,
|
|
16
|
+
AnagramOptions,
|
|
17
|
+
RhymeOptions,
|
|
18
|
+
TDKConfig,
|
|
12
19
|
} from "./types";
|
|
13
20
|
import { TDKValidationError, TDKNetworkError } from "./errors";
|
|
21
|
+
import { getStemCandidates } from "./morphology";
|
|
14
22
|
import * as fs from "node:fs";
|
|
15
23
|
import * as path from "node:path";
|
|
16
24
|
import * as os from "node:os";
|
|
@@ -102,11 +110,28 @@ yDFx8r7i9vIJU5HS3moZLkYWAOilMaV9N56A9Bgb6dNcHkvg3NoaYA==
|
|
|
102
110
|
-----END CERTIFICATE-----`,
|
|
103
111
|
];
|
|
104
112
|
|
|
113
|
+
// Configuration
|
|
114
|
+
private static defaultTimeoutMs = 8000;
|
|
115
|
+
private static defaultRetries = 1;
|
|
116
|
+
private static maxCacheSize = 1000;
|
|
117
|
+
|
|
105
118
|
// Cache Mechanism
|
|
106
119
|
private static isCacheEnabled = false;
|
|
107
120
|
private static wordCache = new Map<string, WordInfo[]>();
|
|
108
121
|
private static dailyContentCache: DailyContent | null = null;
|
|
109
122
|
private static autocompleteCache: string[] = [];
|
|
123
|
+
private static autocompleteSet: Set<string> = new Set<string>();
|
|
124
|
+
private static stemCache = new Map<string, string | null>();
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Configures global client options such as network timeout, retries, and cache size.
|
|
128
|
+
*/
|
|
129
|
+
public static configure(config: TDKConfig): void {
|
|
130
|
+
if (config.timeoutMs !== undefined) this.defaultTimeoutMs = Math.max(100, config.timeoutMs);
|
|
131
|
+
if (config.retries !== undefined) this.defaultRetries = Math.max(0, config.retries);
|
|
132
|
+
if (config.cache !== undefined) this.enableCache(config.cache);
|
|
133
|
+
if (config.maxCacheSize !== undefined) this.maxCacheSize = Math.max(10, config.maxCacheSize);
|
|
134
|
+
}
|
|
110
135
|
|
|
111
136
|
/**
|
|
112
137
|
* Enables or disables in-memory caching for API requests.
|
|
@@ -125,12 +150,62 @@ yDFx8r7i9vIJU5HS3moZLkYWAOilMaV9N56A9Bgb6dNcHkvg3NoaYA==
|
|
|
125
150
|
this.wordCache.clear();
|
|
126
151
|
this.dailyContentCache = null;
|
|
127
152
|
this.autocompleteCache = [];
|
|
153
|
+
this.autocompleteSet.clear();
|
|
154
|
+
this.stemCache.clear();
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
private static setBoundedCache<K, V>(map: Map<K, V>, key: K, value: V): void {
|
|
158
|
+
if (map.size >= this.maxCacheSize) {
|
|
159
|
+
const firstKey = map.keys().next().value;
|
|
160
|
+
if (firstKey !== undefined) map.delete(firstKey);
|
|
161
|
+
}
|
|
162
|
+
map.set(key, value);
|
|
128
163
|
}
|
|
129
164
|
|
|
130
165
|
private static delay(ms: number) {
|
|
131
166
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
132
167
|
}
|
|
133
168
|
|
|
169
|
+
/**
|
|
170
|
+
* Internal helper that performs HTTP fetch with timeout and automatic retry on network/5xx errors.
|
|
171
|
+
*/
|
|
172
|
+
private static async fetchWithRetry(
|
|
173
|
+
url: string,
|
|
174
|
+
options: RequestInit = {},
|
|
175
|
+
retries: number = this.defaultRetries,
|
|
176
|
+
timeoutMs: number = this.defaultTimeoutMs
|
|
177
|
+
): Promise<Response> {
|
|
178
|
+
let lastError: unknown;
|
|
179
|
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
180
|
+
try {
|
|
181
|
+
const signal = AbortSignal.timeout(timeoutMs);
|
|
182
|
+
const headers = {
|
|
183
|
+
"User-Agent": "TDK-API-Nodejs-Wrapper/1.0",
|
|
184
|
+
...((options.headers as Record<string, string>) || {}),
|
|
185
|
+
};
|
|
186
|
+
const res = await fetch(url, { ...options, headers, signal });
|
|
187
|
+
if (res.ok || (res.status >= 400 && res.status < 500)) {
|
|
188
|
+
return res;
|
|
189
|
+
}
|
|
190
|
+
// If 5xx server error, retry
|
|
191
|
+
if (attempt < retries) {
|
|
192
|
+
await this.delay(200 * (attempt + 1));
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
return res;
|
|
196
|
+
} catch (err) {
|
|
197
|
+
lastError = err;
|
|
198
|
+
if (attempt < retries) {
|
|
199
|
+
await this.delay(200 * (attempt + 1));
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
throw new TDKNetworkError(`Request to ${url} failed after ${retries + 1} attempts.`, {
|
|
205
|
+
cause: lastError,
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
134
209
|
/**
|
|
135
210
|
* Fetches detailed information for a given word from the TDK Dictionary.
|
|
136
211
|
*/
|
|
@@ -149,9 +224,7 @@ yDFx8r7i9vIJU5HS3moZLkYWAOilMaV9N56A9Bgb6dNcHkvg3NoaYA==
|
|
|
149
224
|
|
|
150
225
|
let response: Response;
|
|
151
226
|
try {
|
|
152
|
-
response = await
|
|
153
|
-
headers: { "User-Agent": "TDK-API-Nodejs-Wrapper/1.0" },
|
|
154
|
-
});
|
|
227
|
+
response = await this.fetchWithRetry(url);
|
|
155
228
|
} catch (error) {
|
|
156
229
|
throw new TDKNetworkError("Failed to fetch word from TDK: request failed.", { cause: error });
|
|
157
230
|
}
|
|
@@ -170,13 +243,13 @@ yDFx8r7i9vIJU5HS3moZLkYWAOilMaV9N56A9Bgb6dNcHkvg3NoaYA==
|
|
|
170
243
|
}
|
|
171
244
|
|
|
172
245
|
if (!Array.isArray(data) && data && "error" in (data as Record<string, unknown>)) {
|
|
173
|
-
if (this.isCacheEnabled) this.wordCache
|
|
246
|
+
if (this.isCacheEnabled) this.setBoundedCache(this.wordCache, cleanWord, []);
|
|
174
247
|
return [];
|
|
175
248
|
}
|
|
176
249
|
|
|
177
250
|
const results = data as WordInfo[];
|
|
178
251
|
if (this.isCacheEnabled) {
|
|
179
|
-
this.wordCache
|
|
252
|
+
this.setBoundedCache(this.wordCache, cleanWord, results);
|
|
180
253
|
}
|
|
181
254
|
return results;
|
|
182
255
|
}
|
|
@@ -241,6 +314,18 @@ yDFx8r7i9vIJU5HS3moZLkYWAOilMaV9N56A9Bgb6dNcHkvg3NoaYA==
|
|
|
241
314
|
}
|
|
242
315
|
}
|
|
243
316
|
|
|
317
|
+
/**
|
|
318
|
+
* Ensures TDK's ~81k headword list is loaded in memory for fast O(1) set operations.
|
|
319
|
+
*/
|
|
320
|
+
private static async ensureAutocompleteLoaded(): Promise<void> {
|
|
321
|
+
if (this.autocompleteCache.length === 0) {
|
|
322
|
+
this.autocompleteCache = await this.fetchAutocompleteData();
|
|
323
|
+
this.autocompleteSet = new Set(
|
|
324
|
+
this.autocompleteCache.map((w) => w.toLocaleLowerCase("tr-TR"))
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
244
329
|
/**
|
|
245
330
|
* Returns autocomplete suggestions for a given prefix, searched over TDK's
|
|
246
331
|
* full headword list (see `fetchAutocompleteData`). The list is fetched
|
|
@@ -250,9 +335,7 @@ yDFx8r7i9vIJU5HS3moZLkYWAOilMaV9N56A9Bgb6dNcHkvg3NoaYA==
|
|
|
250
335
|
public static async getSuggestions(prefix: string): Promise<string[]> {
|
|
251
336
|
if (!prefix || prefix.trim() === "") return [];
|
|
252
337
|
|
|
253
|
-
|
|
254
|
-
this.autocompleteCache = await this.fetchAutocompleteData();
|
|
255
|
-
}
|
|
338
|
+
await this.ensureAutocompleteLoaded();
|
|
256
339
|
|
|
257
340
|
const cleanPrefix = prefix.trim().toLocaleLowerCase("tr-TR");
|
|
258
341
|
return this.autocompleteCache
|
|
@@ -260,6 +343,88 @@ yDFx8r7i9vIJU5HS3moZLkYWAOilMaV9N56A9Bgb6dNcHkvg3NoaYA==
|
|
|
260
343
|
.slice(0, 10);
|
|
261
344
|
}
|
|
262
345
|
|
|
346
|
+
/**
|
|
347
|
+
* Checks whether a word exists as a known headword in TDK dictionary.
|
|
348
|
+
* Checks in-memory autocompleteSet (81k headwords) if loaded, or queries TDK API.
|
|
349
|
+
*/
|
|
350
|
+
public static async isHeadword(word: string): Promise<boolean> {
|
|
351
|
+
if (!word || word.trim() === "") return false;
|
|
352
|
+
const clean = word.trim().toLocaleLowerCase("tr-TR");
|
|
353
|
+
|
|
354
|
+
await this.ensureAutocompleteLoaded();
|
|
355
|
+
if (this.autocompleteSet.size > 0) {
|
|
356
|
+
return this.autocompleteSet.has(clean);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
try {
|
|
360
|
+
const results = await this.getWord(clean);
|
|
361
|
+
return results.length > 0;
|
|
362
|
+
} catch {
|
|
363
|
+
return false;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Generates candidate roots for a given Turkish word using progressive BFS suffix stripping,
|
|
369
|
+
* consonant mutation restoration, and vowel drop restoration.
|
|
370
|
+
*/
|
|
371
|
+
public static getStemCandidates(word: string): string[] {
|
|
372
|
+
return getStemCandidates(word);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Finds the dictionary root (headword) of a word by checking direct existence
|
|
377
|
+
* and evaluating candidate stems generated by morphological analysis.
|
|
378
|
+
* Returns the root headword string if found, or null if no match in TDK.
|
|
379
|
+
*/
|
|
380
|
+
public static async findRoot(word: string): Promise<string | null> {
|
|
381
|
+
if (!word || word.trim() === "") return null;
|
|
382
|
+
const clean = word.trim().toLocaleLowerCase("tr-TR");
|
|
383
|
+
|
|
384
|
+
if (this.stemCache.has(clean)) {
|
|
385
|
+
return this.stemCache.get(clean)!;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// 1. If the word itself is an exact headword, it is its own root
|
|
389
|
+
if (await this.isHeadword(clean)) {
|
|
390
|
+
this.setBoundedCache(this.stemCache, clean, clean);
|
|
391
|
+
return clean;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// 2. Test morphological stem candidates
|
|
395
|
+
const candidates = getStemCandidates(clean);
|
|
396
|
+
for (const candidate of candidates) {
|
|
397
|
+
if (await this.isHeadword(candidate)) {
|
|
398
|
+
this.setBoundedCache(this.stemCache, clean, candidate);
|
|
399
|
+
return candidate;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
this.setBoundedCache(this.stemCache, clean, null);
|
|
404
|
+
return null;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Performs morphological stemming on a Turkish word.
|
|
409
|
+
* Returns a StemResult containing the original word, resolved root, and whether it is inflected.
|
|
410
|
+
*/
|
|
411
|
+
public static async stem(word: string): Promise<StemResult | null> {
|
|
412
|
+
if (!word || word.trim() === "") return null;
|
|
413
|
+
const clean = word.trim().toLocaleLowerCase("tr-TR");
|
|
414
|
+
const root = await this.findRoot(word);
|
|
415
|
+
|
|
416
|
+
if (!root) {
|
|
417
|
+
return null;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
return {
|
|
421
|
+
word,
|
|
422
|
+
root,
|
|
423
|
+
isInflected: root !== clean,
|
|
424
|
+
candidates: getStemCandidates(word),
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
|
|
263
428
|
/**
|
|
264
429
|
* Returns a list of proverbs and idioms containing the word.
|
|
265
430
|
*/
|
|
@@ -486,7 +651,21 @@ yDFx8r7i9vIJU5HS3moZLkYWAOilMaV9N56A9Bgb6dNcHkvg3NoaYA==
|
|
|
486
651
|
}
|
|
487
652
|
}
|
|
488
653
|
|
|
489
|
-
// 3.
|
|
654
|
+
// 3. Morphology Fallback: Check if the word is an inflected form of a known headword
|
|
655
|
+
// (e.g., "halılarımızın" -> "halı", "kitabımız" -> "kitap", "çocuğa" -> "çocuk")
|
|
656
|
+
const root = await this.findRoot(word);
|
|
657
|
+
if (root) {
|
|
658
|
+
const cleanWord = word.trim().toLocaleLowerCase("tr-TR");
|
|
659
|
+
const isInflected = root !== cleanWord;
|
|
660
|
+
return {
|
|
661
|
+
isCorrect: true,
|
|
662
|
+
word,
|
|
663
|
+
isInflected,
|
|
664
|
+
root,
|
|
665
|
+
};
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// 4. No exact match or morphology root: fall back to the closest
|
|
490
669
|
// headword (by edit distance) across TDK's full ~81k-word list (the same
|
|
491
670
|
// data `getSuggestions()` uses). Restricted to single-token, lowercase
|
|
492
671
|
// headwords so it doesn't suggest compounds/phrases or proper nouns.
|
|
@@ -951,6 +1130,7 @@ yDFx8r7i9vIJU5HS3moZLkYWAOilMaV9N56A9Bgb6dNcHkvg3NoaYA==
|
|
|
951
1130
|
origin: originA,
|
|
952
1131
|
syllables: this.syllabicate(a),
|
|
953
1132
|
harmony: this.checkVowelHarmony(a),
|
|
1133
|
+
labialHarmony: this.checkLabialHarmony(a),
|
|
954
1134
|
},
|
|
955
1135
|
b: {
|
|
956
1136
|
word: b,
|
|
@@ -958,6 +1138,7 @@ yDFx8r7i9vIJU5HS3moZLkYWAOilMaV9N56A9Bgb6dNcHkvg3NoaYA==
|
|
|
958
1138
|
origin: originB,
|
|
959
1139
|
syllables: this.syllabicate(b),
|
|
960
1140
|
harmony: this.checkVowelHarmony(b),
|
|
1141
|
+
labialHarmony: this.checkLabialHarmony(b),
|
|
961
1142
|
},
|
|
962
1143
|
};
|
|
963
1144
|
}
|
|
@@ -997,13 +1178,30 @@ yDFx8r7i9vIJU5HS3moZLkYWAOilMaV9N56A9Bgb6dNcHkvg3NoaYA==
|
|
|
997
1178
|
|
|
998
1179
|
const analyses: WordAnalysis[] = [];
|
|
999
1180
|
for (const word of unique) {
|
|
1000
|
-
|
|
1001
|
-
|
|
1181
|
+
let results = await this.getWord(word);
|
|
1182
|
+
let found = results.length > 0;
|
|
1183
|
+
let root: string | undefined;
|
|
1184
|
+
let isInflected: boolean | undefined;
|
|
1185
|
+
|
|
1186
|
+
if (!found) {
|
|
1187
|
+
const resolvedRoot = await this.findRoot(word);
|
|
1188
|
+
if (resolvedRoot) {
|
|
1189
|
+
results = await this.getWord(resolvedRoot);
|
|
1190
|
+
if (results.length > 0) {
|
|
1191
|
+
found = true;
|
|
1192
|
+
root = resolvedRoot;
|
|
1193
|
+
isInflected = true;
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1002
1198
|
analyses.push({
|
|
1003
1199
|
word,
|
|
1004
1200
|
found,
|
|
1005
1201
|
meaning: found ? this.firstMeaning(results) : null,
|
|
1006
1202
|
origin: found ? results[0].lisan || "Türkçe" : null,
|
|
1203
|
+
root,
|
|
1204
|
+
isInflected,
|
|
1007
1205
|
});
|
|
1008
1206
|
await this.delay(200);
|
|
1009
1207
|
}
|
|
@@ -1052,13 +1250,15 @@ yDFx8r7i9vIJU5HS3moZLkYWAOilMaV9N56A9Bgb6dNcHkvg3NoaYA==
|
|
|
1052
1250
|
|
|
1053
1251
|
/**
|
|
1054
1252
|
* Syllabicates a Turkish word based on general grammar rules.
|
|
1253
|
+
* Handles syllable separation for vowels, single consonants, double consonants,
|
|
1254
|
+
* and western loanword three-consonant clusters (e.g. e-lek-trik, kon-trol, or-kes-tra).
|
|
1055
1255
|
*/
|
|
1056
1256
|
public static syllabicate(word: string): string[] {
|
|
1057
1257
|
const vowels = /[aeıioöuüAEIİOÖUÜ]/;
|
|
1258
|
+
const ONSET_CLUSTERS = new Set(["tr", "pr", "kr", "gr", "br", "fr", "dr", "pl", "kl", "fl", "bl", "gl"]);
|
|
1058
1259
|
const result: string[] = [];
|
|
1059
1260
|
let currentSyllable = "";
|
|
1060
1261
|
|
|
1061
|
-
// Better basic syllabification:
|
|
1062
1262
|
// Go from right to left.
|
|
1063
1263
|
for (let i = word.length - 1; i >= 0; i--) {
|
|
1064
1264
|
currentSyllable = word[i] + currentSyllable;
|
|
@@ -1071,9 +1271,14 @@ yDFx8r7i9vIJU5HS3moZLkYWAOilMaV9N56A9Bgb6dNcHkvg3NoaYA==
|
|
|
1071
1271
|
currentSyllable = word[i - 1] + currentSyllable;
|
|
1072
1272
|
i--; // skip the consonant
|
|
1073
1273
|
} else if (i - 2 >= 0 && !vowels.test(word[i - 2])) {
|
|
1074
|
-
//
|
|
1075
|
-
|
|
1076
|
-
|
|
1274
|
+
// Two consonants before this vowel. Check if three consonants exist and end in an onset cluster
|
|
1275
|
+
if (i - 3 >= 0 && !vowels.test(word[i - 3]) && ONSET_CLUSTERS.has((word[i - 2] + word[i - 1]).toLowerCase())) {
|
|
1276
|
+
currentSyllable = word[i - 2] + word[i - 1] + currentSyllable;
|
|
1277
|
+
i -= 2;
|
|
1278
|
+
} else {
|
|
1279
|
+
currentSyllable = word[i - 1] + currentSyllable;
|
|
1280
|
+
i--;
|
|
1281
|
+
}
|
|
1077
1282
|
}
|
|
1078
1283
|
}
|
|
1079
1284
|
result.unshift(currentSyllable);
|
|
@@ -1107,4 +1312,344 @@ yDFx8r7i9vIJU5HS3moZLkYWAOilMaV9N56A9Bgb6dNcHkvg3NoaYA==
|
|
|
1107
1312
|
// If it has both front and back vowels, it breaks harmony.
|
|
1108
1313
|
return !(hasBack && hasFront);
|
|
1109
1314
|
}
|
|
1315
|
+
|
|
1316
|
+
/**
|
|
1317
|
+
* Checks if a word follows Turkish Minor Vowel Harmony (Küçük Ünlü Uyumu / Labial Harmony).
|
|
1318
|
+
* Rules:
|
|
1319
|
+
* 1. After an unrounded vowel (a, e, ı, i), only unrounded vowels (a, e, ı, i) can follow.
|
|
1320
|
+
* 2. After a rounded vowel (o, ö, u, ü), either an unrounded wide (a, e) or rounded narrow (u, ü) vowel can follow.
|
|
1321
|
+
* Single-syllable words and words with <=1 vowel are considered compliant by convention.
|
|
1322
|
+
*/
|
|
1323
|
+
public static checkLabialHarmony(word: string): boolean {
|
|
1324
|
+
const lower = word.toLocaleLowerCase("tr-TR");
|
|
1325
|
+
const vowels = lower.split("").filter((ch) => "aeıioöuü".includes(ch));
|
|
1326
|
+
if (vowels.length <= 1) return true;
|
|
1327
|
+
|
|
1328
|
+
for (let i = 0; i < vowels.length - 1; i++) {
|
|
1329
|
+
const v1 = vowels[i];
|
|
1330
|
+
const v2 = vowels[i + 1];
|
|
1331
|
+
|
|
1332
|
+
if ("aeıi".includes(v1)) {
|
|
1333
|
+
if (!"aeıi".includes(v2)) return false;
|
|
1334
|
+
} else if ("oöuü".includes(v1)) {
|
|
1335
|
+
if (!"aeuü".includes(v2)) return false;
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
return true;
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
/**
|
|
1342
|
+
* Searches TDK headwords using a wildcard / pattern string.
|
|
1343
|
+
* Wildcards:
|
|
1344
|
+
* '_' or '?' matches any single character
|
|
1345
|
+
* '*' matches zero or more characters
|
|
1346
|
+
* Example: "k_l_m" matches "kalem", "kelam", "kilim".
|
|
1347
|
+
* Runs in-memory against TDK's 81k headword list.
|
|
1348
|
+
*/
|
|
1349
|
+
public static async patternSearch(pattern: string, options?: PatternSearchOptions): Promise<string[]> {
|
|
1350
|
+
if (!pattern || pattern.trim() === "") return [];
|
|
1351
|
+
await this.ensureAutocompleteLoaded();
|
|
1352
|
+
|
|
1353
|
+
const cleanPattern = pattern.trim().toLocaleLowerCase("tr-TR");
|
|
1354
|
+
const escaped = cleanPattern
|
|
1355
|
+
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
|
|
1356
|
+
.replace(/[_?]/g, "[\\p{L}]")
|
|
1357
|
+
.replace(/\*/g, "[\\p{L}]*");
|
|
1358
|
+
const regex = new RegExp(`^${escaped}$`, "u");
|
|
1359
|
+
|
|
1360
|
+
const max = options?.maxResults ?? 50;
|
|
1361
|
+
const matches: string[] = [];
|
|
1362
|
+
|
|
1363
|
+
for (const headword of this.autocompleteCache) {
|
|
1364
|
+
const lower = headword.toLocaleLowerCase("tr-TR");
|
|
1365
|
+
if (regex.test(lower)) {
|
|
1366
|
+
matches.push(headword);
|
|
1367
|
+
if (matches.length >= max) break;
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
1370
|
+
return matches;
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
/**
|
|
1374
|
+
* Finds headwords in TDK that can be formed from the given letters (anagrams).
|
|
1375
|
+
* If exact-length anagrams exist, they are returned.
|
|
1376
|
+
* If none exist (or exactLength is false), valid sub-anagrams (words using a subset of the letters,
|
|
1377
|
+
* minimum 3 letters) are returned, sorted by length descending.
|
|
1378
|
+
*/
|
|
1379
|
+
public static async findAnagrams(letters: string, options?: AnagramOptions): Promise<string[]> {
|
|
1380
|
+
if (!letters || letters.trim() === "") return [];
|
|
1381
|
+
await this.ensureAutocompleteLoaded();
|
|
1382
|
+
|
|
1383
|
+
const clean = letters.trim().toLocaleLowerCase("tr-TR").replace(/[^a-zçğıöşüâîû]/gi, "");
|
|
1384
|
+
if (clean.length === 0) return [];
|
|
1385
|
+
|
|
1386
|
+
const forceExact = options?.exactLength === true;
|
|
1387
|
+
const max = options?.maxResults ?? 50;
|
|
1388
|
+
|
|
1389
|
+
const getFrequency = (str: string): Record<string, number> => {
|
|
1390
|
+
const freq: Record<string, number> = {};
|
|
1391
|
+
for (const ch of str) {
|
|
1392
|
+
freq[ch] = (freq[ch] || 0) + 1;
|
|
1393
|
+
}
|
|
1394
|
+
return freq;
|
|
1395
|
+
};
|
|
1396
|
+
|
|
1397
|
+
const targetFreq = getFrequency(clean);
|
|
1398
|
+
const exactMatches: string[] = [];
|
|
1399
|
+
const subMatches: string[] = [];
|
|
1400
|
+
|
|
1401
|
+
for (const headword of this.autocompleteCache) {
|
|
1402
|
+
const lower = headword.toLocaleLowerCase("tr-TR");
|
|
1403
|
+
if (lower.includes(" ") || lower.includes("-")) continue;
|
|
1404
|
+
if (lower.length > clean.length || lower.length < 3) continue;
|
|
1405
|
+
|
|
1406
|
+
const wordFreq = getFrequency(lower);
|
|
1407
|
+
let isValid = true;
|
|
1408
|
+
for (const [ch, count] of Object.entries(wordFreq)) {
|
|
1409
|
+
if (!targetFreq[ch] || targetFreq[ch] < count) {
|
|
1410
|
+
isValid = false;
|
|
1411
|
+
break;
|
|
1412
|
+
}
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
if (isValid && lower !== clean) {
|
|
1416
|
+
if (lower.length === clean.length) {
|
|
1417
|
+
exactMatches.push(headword);
|
|
1418
|
+
} else {
|
|
1419
|
+
subMatches.push(headword);
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
if (exactMatches.length > 0 || forceExact) {
|
|
1425
|
+
return exactMatches.slice(0, max);
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
subMatches.sort((a, b) => b.length - a.length || a.localeCompare(b, "tr-TR"));
|
|
1429
|
+
return subMatches.slice(0, max);
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
/**
|
|
1433
|
+
* Finds words in TDK that rhyme with the given word (sharing the same ending suffix/letters).
|
|
1434
|
+
* @param word The target word
|
|
1435
|
+
* @param options.minLetters Minimum number of ending characters that must match (default: 3)
|
|
1436
|
+
* @param options.maxResults Maximum number of rhyme results to return (default: 50)
|
|
1437
|
+
*/
|
|
1438
|
+
public static async findRhymes(word: string, options?: RhymeOptions): Promise<string[]> {
|
|
1439
|
+
if (!word || word.trim() === "") return [];
|
|
1440
|
+
await this.ensureAutocompleteLoaded();
|
|
1441
|
+
|
|
1442
|
+
const clean = word.trim().toLocaleLowerCase("tr-TR");
|
|
1443
|
+
const minLetters = Math.min(options?.minLetters ?? 3, clean.length);
|
|
1444
|
+
const max = options?.maxResults ?? 50;
|
|
1445
|
+
|
|
1446
|
+
const suffix = clean.slice(-minLetters);
|
|
1447
|
+
const results: string[] = [];
|
|
1448
|
+
|
|
1449
|
+
for (const headword of this.autocompleteCache) {
|
|
1450
|
+
const lower = headword.toLocaleLowerCase("tr-TR");
|
|
1451
|
+
if (lower !== clean && lower.endsWith(suffix) && !lower.includes(" ")) {
|
|
1452
|
+
results.push(headword);
|
|
1453
|
+
if (results.length >= max) break;
|
|
1454
|
+
}
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
return results;
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
/**
|
|
1461
|
+
* Performs comprehensive spelling, grammar, and syntax proofreading on a Turkish text.
|
|
1462
|
+
* Detects:
|
|
1463
|
+
* 1. Conjunction 'da/de' erroneously joined to verbs or words (e.g. "gitsende" -> "gitsen de")
|
|
1464
|
+
* 2. Conjunction 'ki' erroneously joined to verbs (e.g. "gördümki" -> "gördüm ki"), respecting SOMBAHÇEMİ exceptions
|
|
1465
|
+
* 3. Question particle 'mi/mı/mu/mü' erroneously joined to words (e.g. "geldimi" -> "geldi mi")
|
|
1466
|
+
* 4. Misspelled words with dictionary suggestions (via edit-distance & morphology)
|
|
1467
|
+
*/
|
|
1468
|
+
public static async proofread(text: string): Promise<ProofreadResult> {
|
|
1469
|
+
if (!text || text.trim() === "") {
|
|
1470
|
+
return { text: text || "", issues: [], isCorrect: true };
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
await this.ensureAutocompleteLoaded();
|
|
1474
|
+
const issues: ProofreadIssue[] = [];
|
|
1475
|
+
|
|
1476
|
+
const SOMBAHCEMI = new Set([
|
|
1477
|
+
"sanki", "oysaki", "mademki", "belki", "halbuki", "çünkü", "meğerki", "illaki"
|
|
1478
|
+
]);
|
|
1479
|
+
|
|
1480
|
+
const tokenRegex = /[\p{L}0-9'’]+/gu;
|
|
1481
|
+
let match: RegExpExecArray | null;
|
|
1482
|
+
|
|
1483
|
+
while ((match = tokenRegex.exec(text)) !== null) {
|
|
1484
|
+
const rawWord = match[0];
|
|
1485
|
+
const startIndex = match.index;
|
|
1486
|
+
const endIndex = startIndex + rawWord.length;
|
|
1487
|
+
const lower = rawWord.toLocaleLowerCase("tr-TR");
|
|
1488
|
+
|
|
1489
|
+
if (/^\d+$/.test(lower)) continue;
|
|
1490
|
+
|
|
1491
|
+
let flagged = false;
|
|
1492
|
+
|
|
1493
|
+
// 1. Check Question Particle (mı, mi, mu, mü) erroneously attached
|
|
1494
|
+
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)?)$/);
|
|
1495
|
+
if (questionMatch) {
|
|
1496
|
+
const base = questionMatch[1];
|
|
1497
|
+
const particle = questionMatch[2];
|
|
1498
|
+
if (base.length >= 2 && (await this.isHeadword(base) || (await this.findRoot(base)) !== null)) {
|
|
1499
|
+
if (!(await this.isHeadword(lower))) {
|
|
1500
|
+
issues.push({
|
|
1501
|
+
type: "question_particle",
|
|
1502
|
+
word: rawWord,
|
|
1503
|
+
startIndex,
|
|
1504
|
+
endIndex,
|
|
1505
|
+
suggestion: `${base} ${particle}`,
|
|
1506
|
+
message: `'${particle}' soru eki kendinden önceki kelimeden ayrı yazılmalıdır.`,
|
|
1507
|
+
});
|
|
1508
|
+
flagged = true;
|
|
1509
|
+
}
|
|
1510
|
+
}
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
const VERB_CONJUGATION_REGEX =
|
|
1514
|
+
/(?: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;
|
|
1515
|
+
|
|
1516
|
+
// 2. Check Conjunction 'ki' erroneously attached to verbs
|
|
1517
|
+
if (!flagged && lower.endsWith("ki") && lower.length > 3) {
|
|
1518
|
+
const base = lower.slice(0, -2);
|
|
1519
|
+
if (!SOMBAHCEMI.has(lower)) {
|
|
1520
|
+
if (!(await this.isHeadword(lower))) {
|
|
1521
|
+
const root = await this.findRoot(base);
|
|
1522
|
+
const isVerb =
|
|
1523
|
+
(root && (root.endsWith("mek") || root.endsWith("mak"))) ||
|
|
1524
|
+
base === "demek" ||
|
|
1525
|
+
base === "kaldı" ||
|
|
1526
|
+
base === "yeter" ||
|
|
1527
|
+
base === "bilmem" ||
|
|
1528
|
+
VERB_CONJUGATION_REGEX.test(base);
|
|
1529
|
+
|
|
1530
|
+
if (isVerb) {
|
|
1531
|
+
issues.push({
|
|
1532
|
+
type: "conjunction_ki",
|
|
1533
|
+
word: rawWord,
|
|
1534
|
+
startIndex,
|
|
1535
|
+
endIndex,
|
|
1536
|
+
suggestion: `${base} ki`,
|
|
1537
|
+
message: `'ki' bağlacı ayrı yazılmalıdır.`,
|
|
1538
|
+
});
|
|
1539
|
+
flagged = true;
|
|
1540
|
+
}
|
|
1541
|
+
}
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
// 3. Check Conjunction 'da/de/ta/te' erroneously attached to verbs
|
|
1546
|
+
if (!flagged && (lower.endsWith("de") || lower.endsWith("da") || lower.endsWith("te") || lower.endsWith("ta")) && lower.length > 3) {
|
|
1547
|
+
const base = lower.slice(0, -2);
|
|
1548
|
+
const ending = lower.slice(-2);
|
|
1549
|
+
if (!(await this.isHeadword(lower))) {
|
|
1550
|
+
const root = await this.findRoot(base);
|
|
1551
|
+
const isVerb =
|
|
1552
|
+
(root && (root.endsWith("mek") || root.endsWith("mak"))) ||
|
|
1553
|
+
VERB_CONJUGATION_REGEX.test(base);
|
|
1554
|
+
|
|
1555
|
+
if (isVerb) {
|
|
1556
|
+
const correctEnding = ending.startsWith("t") ? (ending === "te" ? "de" : "da") : ending;
|
|
1557
|
+
issues.push({
|
|
1558
|
+
type: "conjunction_da",
|
|
1559
|
+
word: rawWord,
|
|
1560
|
+
startIndex,
|
|
1561
|
+
endIndex,
|
|
1562
|
+
suggestion: `${base} ${correctEnding}`,
|
|
1563
|
+
message: `'da/de' bağlacı fiillerden sonra her zaman ayrı yazılır (bağlaç olan da/de sertleşmez).`,
|
|
1564
|
+
});
|
|
1565
|
+
flagged = true;
|
|
1566
|
+
}
|
|
1567
|
+
}
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
// 4. General Spell Check
|
|
1571
|
+
if (!flagged) {
|
|
1572
|
+
const check = await this.checkSpelling(rawWord);
|
|
1573
|
+
if (!check.isCorrect) {
|
|
1574
|
+
issues.push({
|
|
1575
|
+
type: "spelling",
|
|
1576
|
+
word: rawWord,
|
|
1577
|
+
startIndex,
|
|
1578
|
+
endIndex,
|
|
1579
|
+
suggestion: check.suggestion,
|
|
1580
|
+
message: check.suggestion
|
|
1581
|
+
? `'${rawWord}' yanlış yazılmış olabilir. Öneri: '${check.suggestion}'`
|
|
1582
|
+
: `'${rawWord}' sözlükte bulunamadı.`,
|
|
1583
|
+
});
|
|
1584
|
+
}
|
|
1585
|
+
}
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1588
|
+
return {
|
|
1589
|
+
text,
|
|
1590
|
+
issues,
|
|
1591
|
+
isCorrect: issues.length === 0,
|
|
1592
|
+
};
|
|
1593
|
+
}
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
/**
|
|
1597
|
+
* Configurable instance-based client for TDK API.
|
|
1598
|
+
* Useful for multi-tenant applications or backend services requiring isolated configurations.
|
|
1599
|
+
*/
|
|
1600
|
+
export class TDKClient {
|
|
1601
|
+
constructor(config?: TDKConfig) {
|
|
1602
|
+
if (config) {
|
|
1603
|
+
TDK.configure(config);
|
|
1604
|
+
}
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
public getWord(word: string): Promise<WordInfo[]> {
|
|
1608
|
+
return TDK.getWord(word);
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1611
|
+
public getMeanings(word: string): Promise<string[]> {
|
|
1612
|
+
return TDK.getMeanings(word);
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1615
|
+
public checkSpelling(word: string): Promise<SpellCheckResult> {
|
|
1616
|
+
return TDK.checkSpelling(word);
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1619
|
+
public findRoot(word: string): Promise<string | null> {
|
|
1620
|
+
return TDK.findRoot(word);
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
public stem(word: string): Promise<StemResult | null> {
|
|
1624
|
+
return TDK.stem(word);
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1627
|
+
public proofread(text: string): Promise<ProofreadResult> {
|
|
1628
|
+
return TDK.proofread(text);
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1631
|
+
public patternSearch(pattern: string, options?: PatternSearchOptions): Promise<string[]> {
|
|
1632
|
+
return TDK.patternSearch(pattern, options);
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1635
|
+
public findAnagrams(letters: string, options?: AnagramOptions): Promise<string[]> {
|
|
1636
|
+
return TDK.findAnagrams(letters, options);
|
|
1637
|
+
}
|
|
1638
|
+
|
|
1639
|
+
public findRhymes(word: string, options?: RhymeOptions): Promise<string[]> {
|
|
1640
|
+
return TDK.findRhymes(word, options);
|
|
1641
|
+
}
|
|
1642
|
+
|
|
1643
|
+
public syllabicate(word: string): string[] {
|
|
1644
|
+
return TDK.syllabicate(word);
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1647
|
+
public checkVowelHarmony(word: string): boolean {
|
|
1648
|
+
return TDK.checkVowelHarmony(word);
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
public checkLabialHarmony(word: string): boolean {
|
|
1652
|
+
return TDK.checkLabialHarmony(word);
|
|
1653
|
+
}
|
|
1110
1654
|
}
|
|
1655
|
+
|