i18n-dashboard 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (176) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +715 -0
  3. package/app.vue +8 -0
  4. package/assets/css/main.css +21 -0
  5. package/assets/locales/en.json +380 -0
  6. package/bin/cli.mjs +279 -0
  7. package/components/LinkedKeyPicker.vue +135 -0
  8. package/components/PathPicker.vue +153 -0
  9. package/components/PluralEditor.vue +295 -0
  10. package/components/ScanModal.vue +153 -0
  11. package/components/TranslationHistoryModal.vue +66 -0
  12. package/components/TranslationRow.vue +541 -0
  13. package/components/dashboard/WidgetConfigModal.vue +121 -0
  14. package/components/dashboard/WidgetGrid.vue +190 -0
  15. package/components/dashboard/WidgetPicker.vue +75 -0
  16. package/components/dashboard/widgets/ActivityWidget.vue +109 -0
  17. package/components/dashboard/widgets/LanguagesCoverageWidget.vue +104 -0
  18. package/components/dashboard/widgets/ProjectsWidget.vue +77 -0
  19. package/components/dashboard/widgets/ReviewWidget.vue +150 -0
  20. package/components/dashboard/widgets/StatWidget.vue +133 -0
  21. package/composables/useAuth.ts +72 -0
  22. package/composables/useConfig.ts +14 -0
  23. package/composables/useDashboard.ts +89 -0
  24. package/composables/useFormats.ts +100 -0
  25. package/composables/useKeys.ts +231 -0
  26. package/composables/useLanguages.ts +221 -0
  27. package/composables/useProfile.ts +76 -0
  28. package/composables/useProject.ts +180 -0
  29. package/composables/useReview.ts +94 -0
  30. package/composables/useSettings.ts +30 -0
  31. package/composables/useStats.ts +16 -0
  32. package/composables/useT.ts +38 -0
  33. package/composables/useUsers.ts +101 -0
  34. package/composables/useWidgetData.ts +50 -0
  35. package/consts/commons.const.ts +6 -0
  36. package/consts/dashboard.const.ts +94 -0
  37. package/consts/languages.const.ts +223 -0
  38. package/enums/commons.enum.ts +7 -0
  39. package/i18n-dashboard.config.example.js +40 -0
  40. package/interfaces/commons.interface.ts +23 -0
  41. package/interfaces/job.interface.ts +10 -0
  42. package/interfaces/key.interface.ts +39 -0
  43. package/interfaces/languages.interface.ts +23 -0
  44. package/interfaces/project.interface.ts +9 -0
  45. package/interfaces/scan.interface.ts +12 -0
  46. package/interfaces/settings.interface.ts +4 -0
  47. package/interfaces/stat.interface.ts +30 -0
  48. package/interfaces/translation.interface.ts +11 -0
  49. package/interfaces/user.interface.ts +24 -0
  50. package/layouts/auth.vue +5 -0
  51. package/layouts/default.vue +327 -0
  52. package/middleware/auth.global.ts +26 -0
  53. package/nuxt.config.ts +66 -0
  54. package/package.json +89 -0
  55. package/pages/index.vue +5 -0
  56. package/pages/login.vue +74 -0
  57. package/pages/onboarding.vue +563 -0
  58. package/pages/projects/[id]/formats/datetime.vue +240 -0
  59. package/pages/projects/[id]/formats/modifiers.vue +194 -0
  60. package/pages/projects/[id]/formats/number.vue +250 -0
  61. package/pages/projects/[id]/index.vue +182 -0
  62. package/pages/projects/[id]/languages.vue +537 -0
  63. package/pages/projects/[id]/review.vue +109 -0
  64. package/pages/projects/[id]/settings.vue +515 -0
  65. package/pages/projects/[id]/translations/[keyId].vue +642 -0
  66. package/pages/projects/[id]/translations/index.vue +250 -0
  67. package/pages/projects/[id]/users.vue +276 -0
  68. package/pages/projects/index.vue +334 -0
  69. package/pages/users/[id]/profile.vue +421 -0
  70. package/pages/users/index.vue +345 -0
  71. package/plugins/loading.client.ts +3 -0
  72. package/plugins/ui-i18n.ts +6 -0
  73. package/server/api/auth/login.post.ts +28 -0
  74. package/server/api/auth/logout.post.ts +7 -0
  75. package/server/api/auth/me.get.ts +11 -0
  76. package/server/api/auth/me.put.ts +31 -0
  77. package/server/api/auth/password.put.ts +27 -0
  78. package/server/api/auth/status.get.ts +16 -0
  79. package/server/api/config.get.ts +10 -0
  80. package/server/api/dashboard/layout.get.ts +18 -0
  81. package/server/api/dashboard/layout.post.ts +18 -0
  82. package/server/api/db-config.get.ts +44 -0
  83. package/server/api/db-config.post.ts +73 -0
  84. package/server/api/export.get.ts +64 -0
  85. package/server/api/formats/datetime/[id].delete.ts +8 -0
  86. package/server/api/formats/datetime/[id].put.ts +15 -0
  87. package/server/api/formats/datetime.get.ts +11 -0
  88. package/server/api/formats/datetime.post.ts +16 -0
  89. package/server/api/formats/modifiers/[id].delete.ts +8 -0
  90. package/server/api/formats/modifiers/[id].put.ts +10 -0
  91. package/server/api/formats/modifiers.get.ts +10 -0
  92. package/server/api/formats/modifiers.post.ts +14 -0
  93. package/server/api/formats/number/[id].delete.ts +8 -0
  94. package/server/api/formats/number/[id].put.ts +15 -0
  95. package/server/api/formats/number.get.ts +11 -0
  96. package/server/api/formats/number.post.ts +16 -0
  97. package/server/api/formats/snippet.get.ts +87 -0
  98. package/server/api/fs/browse.get.ts +50 -0
  99. package/server/api/history/[translationId].get.ts +13 -0
  100. package/server/api/keys/[id].delete.ts +14 -0
  101. package/server/api/keys/[id].get.ts +41 -0
  102. package/server/api/keys/[id].patch.ts +20 -0
  103. package/server/api/keys/index.get.ts +98 -0
  104. package/server/api/keys/index.post.ts +17 -0
  105. package/server/api/languages/[code].delete.ts +15 -0
  106. package/server/api/languages/[id].put.ts +24 -0
  107. package/server/api/languages/index.get.ts +13 -0
  108. package/server/api/languages/index.post.ts +42 -0
  109. package/server/api/onboarding.post.ts +56 -0
  110. package/server/api/profile.get.ts +81 -0
  111. package/server/api/project-snapshot.get.ts +73 -0
  112. package/server/api/project-snapshot.post.ts +160 -0
  113. package/server/api/projects/[id].delete.ts +13 -0
  114. package/server/api/projects/[id].put.ts +40 -0
  115. package/server/api/projects/index.get.ts +19 -0
  116. package/server/api/projects/index.post.ts +34 -0
  117. package/server/api/scan.post.ts +165 -0
  118. package/server/api/settings/index.get.ts +9 -0
  119. package/server/api/settings/index.post.ts +20 -0
  120. package/server/api/setup.post.ts +39 -0
  121. package/server/api/stats/global.get.ts +126 -0
  122. package/server/api/stats.get.ts +70 -0
  123. package/server/api/sync.post.ts +179 -0
  124. package/server/api/translate.post.ts +52 -0
  125. package/server/api/translations/batch-translate.post.ts +121 -0
  126. package/server/api/translations/bulk-status.post.ts +24 -0
  127. package/server/api/translations/index.post.ts +62 -0
  128. package/server/api/translations/job/[id].get.ts +23 -0
  129. package/server/api/translations/status.post.ts +30 -0
  130. package/server/api/translations/translate-all.post.ts +18 -0
  131. package/server/api/ui-locale.get.ts +39 -0
  132. package/server/api/users/[id]/profile.get.ts +107 -0
  133. package/server/api/users/[id]/roles.put.ts +67 -0
  134. package/server/api/users/[id].delete.ts +36 -0
  135. package/server/api/users/[id].put.ts +43 -0
  136. package/server/api/users/index.get.ts +49 -0
  137. package/server/api/users/index.post.ts +89 -0
  138. package/server/consts/auto-translate.const.ts +2 -0
  139. package/server/consts/commons.const.ts +10 -0
  140. package/server/consts/db.const.ts +3 -0
  141. package/server/consts/scanner.const.ts +4 -0
  142. package/server/consts/translation-job.const.ts +8 -0
  143. package/server/db/index.ts +672 -0
  144. package/server/enums/auth.enum.ts +5 -0
  145. package/server/enums/translation.enum.ts +6 -0
  146. package/server/interfaces/profile.interface.ts +48 -0
  147. package/server/interfaces/project-config.interface.ts +9 -0
  148. package/server/interfaces/scanner.interface.ts +18 -0
  149. package/server/interfaces/translation-job.interface.ts +13 -0
  150. package/server/middleware/auth.ts +32 -0
  151. package/server/plugins/db.ts +6 -0
  152. package/server/routes/locale/[lang].get.ts +179 -0
  153. package/server/types/auth.type.ts +3 -0
  154. package/server/utils/auth.util.ts +89 -0
  155. package/server/utils/auto-translate.util.ts +112 -0
  156. package/server/utils/lang-api.util.ts +24 -0
  157. package/server/utils/mailer.util.ts +80 -0
  158. package/server/utils/project-config.util.ts +37 -0
  159. package/server/utils/scanner.uti.ts +307 -0
  160. package/server/utils/translation-job.util.ts +142 -0
  161. package/services/auth.service.ts +31 -0
  162. package/services/base.service.ts +140 -0
  163. package/services/job.service.ts +10 -0
  164. package/services/key.service.ts +26 -0
  165. package/services/language.service.ts +26 -0
  166. package/services/profile.service.ts +14 -0
  167. package/services/project.service.ts +23 -0
  168. package/services/scan.service.ts +14 -0
  169. package/services/settings.service.ts +14 -0
  170. package/services/stats.service.ts +11 -0
  171. package/services/translation.service.ts +36 -0
  172. package/services/user.service.ts +28 -0
  173. package/tsconfig.json +3 -0
  174. package/types/commons.type.ts +3 -0
  175. package/types/dashboard.type.ts +26 -0
  176. package/utils/config.util.ts +60 -0
@@ -0,0 +1,307 @@
1
+ import { readdirSync, readFileSync, statSync, existsSync } from 'fs'
2
+ import { resolve, extname, relative, basename } from 'path'
3
+
4
+ import type { DetectedLanguage, KeyUsage, ScanResult } from '../interfaces/scanner.interface'
5
+ import { AVAILABLE_LOCALES_PATTERN, LOCALE_ARRAY_PATTERN, LOCALE_SINGLE_PATTERN } from '../consts/scanner.const'
6
+ import { LANGUAGES } from '../../consts/languages.const'
7
+
8
+ function langName(code: string): string {
9
+ return LANGUAGES[code.toLowerCase()] || code.toUpperCase()
10
+ }
11
+
12
+ function extractCodesFromArray(str: string): string[] {
13
+ return [...str.matchAll(/['"`]([a-z]{2}(?:-[a-z]{2,4})?)[`'"]/gi)].map((m) => m[1].toLowerCase())
14
+ }
15
+
16
+ /**
17
+ * Detect languages from:
18
+ * 1. JSON files in the locales directory
19
+ * 2. i18n config files in the project (i18n.js, i18n.ts, nuxt.config.ts, etc.)
20
+ */
21
+ export function detectLanguages(options: {
22
+ projectRoot: string
23
+ localesPath: string
24
+ }): DetectedLanguage[] {
25
+ const { projectRoot, localesPath } = options
26
+ const found = new Map<string, DetectedLanguage>()
27
+
28
+ // ── 1. Locale JSON files ──────────────────────────────────────────────
29
+ const absLocalesPath = resolve(projectRoot, localesPath)
30
+ if (existsSync(absLocalesPath)) {
31
+ try {
32
+ const files = readdirSync(absLocalesPath)
33
+ for (const file of files) {
34
+ if (extname(file) !== '.json') continue
35
+ const code = basename(file, '.json').toLowerCase()
36
+ if (/^[a-z]{2}(-[a-z]{2,4})?$/.test(code)) {
37
+ found.set(code, { code, name: langName(code), source: 'locales-dir' })
38
+ }
39
+ }
40
+ } catch { /* ignore */ }
41
+ }
42
+
43
+ // ── 2. Scan config files for locale declarations ───────────────────────
44
+ const configFileNames = [
45
+ 'i18n.js', 'i18n.ts', 'i18n.mjs', 'i18n.mts',
46
+ 'i18n/index.js', 'i18n/index.ts',
47
+ 'nuxt.config.js', 'nuxt.config.ts', 'nuxt.config.mjs',
48
+ 'vite.config.js', 'vite.config.ts',
49
+ 'vue.config.js', 'vue.config.ts',
50
+ 'src/i18n.js', 'src/i18n.ts', 'src/i18n/index.js', 'src/i18n/index.ts',
51
+ 'src/plugins/i18n.js', 'src/plugins/i18n.ts',
52
+ ]
53
+
54
+ for (const relPath of configFileNames) {
55
+ const absPath = resolve(projectRoot, relPath)
56
+ if (!existsSync(absPath)) continue
57
+
58
+ let content: string
59
+ try { content = readFileSync(absPath, 'utf-8') } catch { continue }
60
+
61
+ // locales: ['fr', 'en']
62
+ for (const match of content.matchAll(LOCALE_ARRAY_PATTERN)) {
63
+ for (const code of extractCodesFromArray(match[1])) {
64
+ if (!found.has(code)) found.set(code, { code, name: langName(code), source: 'config-file' })
65
+ }
66
+ }
67
+
68
+ // locales: [{ code: 'fr' }, ...]
69
+ const objPattern = /locales\s*:\s*\[[\s\S]*?code\s*:\s*['"]([a-z]{2}(?:-[a-z]{2,4})?)['"]/gi
70
+ for (const match of content.matchAll(objPattern)) {
71
+ const code = match[1].toLowerCase()
72
+ if (!found.has(code)) found.set(code, { code, name: langName(code), source: 'config-file' })
73
+ }
74
+
75
+ // availableLocales: ['fr', 'en']
76
+ for (const match of content.matchAll(AVAILABLE_LOCALES_PATTERN)) {
77
+ for (const code of extractCodesFromArray(match[1])) {
78
+ if (!found.has(code)) found.set(code, { code, name: langName(code), source: 'config-file' })
79
+ }
80
+ }
81
+
82
+ // locale: 'fr', defaultLocale: 'fr', fallbackLocale: 'en'
83
+ for (const match of content.matchAll(LOCALE_SINGLE_PATTERN)) {
84
+ const code = match[1].toLowerCase()
85
+ if (!found.has(code)) found.set(code, { code, name: langName(code), source: 'config-file' })
86
+ }
87
+ }
88
+
89
+ return [...found.values()]
90
+ }
91
+
92
+ // All vue-i18n function call patterns
93
+ const PATTERNS: Array<{ regex: RegExp; fn: string }> = [
94
+ // Template: $t('key'), $t("key"), $t(`key`)
95
+ { regex: /\$t\s*\(\s*['"`]([^'"`\n]+)['"`]/g, fn: '$t' },
96
+ // Template: $tc('key'), $te('key'), $tm('key')
97
+ { regex: /\$tc\s*\(\s*['"`]([^'"`\n]+)['"`]/g, fn: '$tc' },
98
+ { regex: /\$te\s*\(\s*['"`]([^'"`\n]+)['"`]/g, fn: '$te' },
99
+ { regex: /\$tm\s*\(\s*['"`]([^'"`\n]+)['"`]/g, fn: '$tm' },
100
+ // i18n.t(), i18n.global.t()
101
+ { regex: /i18n(?:\.global)?\.t\s*\(\s*['"`]([^'"`\n]+)['"`]/g, fn: 'i18n.t' },
102
+ { regex: /i18n(?:\.global)?\.tc\s*\(\s*['"`]([^'"`\n]+)['"`]/g, fn: 'i18n.tc' },
103
+ // <i18n-t keypath="key"> component
104
+ { regex: /keypath\s*=\s*['"]([^'"]+)['"]/g, fn: 'i18n-t' },
105
+ // v-t directive: v-t="'key'" or v-t='"key"'
106
+ { regex: /v-t\s*=\s*"'([^']+)'"/g, fn: 'v-t' },
107
+ { regex: /v-t\s*=\s*'"([^"]+)"'/g, fn: 'v-t' },
108
+ { regex: /v-t\s*=\s*'([^']+)'/g, fn: 'v-t' },
109
+ ]
110
+
111
+ // Pattern to detect useI18n() in a file — to allow t() scanning
112
+ const USE_I18N_PATTERN = /useI18n\s*\(/
113
+ // Pattern for t() only when useI18n is present
114
+ const T_FUNCTION_PATTERN = /(?<![.$\w])t\s*\(\s*['"`]([^'"`\n]+)['"`]/g
115
+ const TC_FUNCTION_PATTERN = /(?<![.$\w])tc\s*\(\s*['"`]([^'"`\n]+)['"`]/g
116
+ const TE_FUNCTION_PATTERN = /(?<![.$\w])te\s*\(\s*['"`]([^'"`\n]+)['"`]/g
117
+ const TM_FUNCTION_PATTERN = /(?<![.$\w])tm\s*\(\s*['"`]([^'"`\n]+)['"`]/g
118
+
119
+ /**
120
+ * Parse an <i18n> custom block from a .vue SFC and extract all keys
121
+ */
122
+ function extractI18nBlock(content: string, filePath: string): KeyUsage[] {
123
+ const usages: KeyUsage[] = []
124
+ const i18nBlockRegex = /<i18n(?:\s[^>]*)?>[\s\S]*?<\/i18n>/g
125
+ const blockMatch = i18nBlockRegex.exec(content)
126
+
127
+ if (!blockMatch) return usages
128
+
129
+ const block = blockMatch[0]
130
+ // Get the line number of the block
131
+ const linesBefore = content.slice(0, blockMatch.index).split('\n').length
132
+
133
+ try {
134
+ // Extract JSON content between tags
135
+ const jsonMatch = block.match(/<i18n(?:\s[^>]*)?>[\s\S]*?(\{[\s\S]+\})\s*<\/i18n>/)
136
+ if (!jsonMatch) return usages
137
+
138
+ const json = JSON.parse(jsonMatch[1])
139
+
140
+ // Keys may be nested under locale codes: { "en": { "key": "val" } }
141
+ // or directly: { "key": "val" }
142
+ // Detect by checking if top-level values are objects (locale-keyed)
143
+ const firstVal = Object.values(json)[0]
144
+ const messages = (firstVal && typeof firstVal === 'object' && !Array.isArray(firstVal))
145
+ ? firstVal as Record<string, any>
146
+ : json
147
+
148
+ function extractKeys(obj: Record<string, any>, prefix = ''): void {
149
+ for (const [k, v] of Object.entries(obj)) {
150
+ const key = prefix ? `${prefix}.${k}` : k
151
+ if (v && typeof v === 'object' && !Array.isArray(v)) {
152
+ extractKeys(v, key)
153
+ } else {
154
+ usages.push({ key, filePath, lineNumber: linesBefore, detectedFunction: 'i18n-block' })
155
+ }
156
+ }
157
+ }
158
+
159
+ extractKeys(messages)
160
+ } catch {
161
+ // Ignore parse errors on malformed blocks
162
+ }
163
+
164
+ return usages
165
+ }
166
+
167
+ /**
168
+ * Extract all vue-i18n key usages from a single file
169
+ */
170
+ function scanFile(filePath: string, content: string): KeyUsage[] {
171
+ const usages: KeyUsage[] = []
172
+ const lines = content.split('\n')
173
+ const hasUseI18n = USE_I18N_PATTERN.test(content)
174
+
175
+ function getLineNumber(index: number): number {
176
+ return content.slice(0, index).split('\n').length
177
+ }
178
+
179
+ // Apply all standard patterns
180
+ for (const { regex, fn } of PATTERNS) {
181
+ regex.lastIndex = 0
182
+ let match: RegExpExecArray | null
183
+ while ((match = regex.exec(content)) !== null) {
184
+ const key = match[1].trim()
185
+ if (key && !key.includes('${') && !key.includes('`')) {
186
+ usages.push({
187
+ key,
188
+ filePath,
189
+ lineNumber: getLineNumber(match.index),
190
+ detectedFunction: fn,
191
+ })
192
+ }
193
+ }
194
+ }
195
+
196
+ // t(), tc(), te(), tm() — only if useI18n is used in the file
197
+ if (hasUseI18n) {
198
+ const scriptPatterns = [
199
+ { regex: T_FUNCTION_PATTERN, fn: 't' },
200
+ { regex: TC_FUNCTION_PATTERN, fn: 'tc' },
201
+ { regex: TE_FUNCTION_PATTERN, fn: 'te' },
202
+ { regex: TM_FUNCTION_PATTERN, fn: 'tm' },
203
+ ]
204
+ for (const { regex, fn } of scriptPatterns) {
205
+ regex.lastIndex = 0
206
+ let match: RegExpExecArray | null
207
+ while ((match = regex.exec(content)) !== null) {
208
+ const key = match[1].trim()
209
+ if (key && !key.includes('${') && !key.includes('`')) {
210
+ usages.push({
211
+ key,
212
+ filePath,
213
+ lineNumber: getLineNumber(match.index),
214
+ detectedFunction: fn,
215
+ })
216
+ }
217
+ }
218
+ }
219
+ }
220
+
221
+ // <i18n> blocks in .vue files
222
+ if (filePath.endsWith('.vue')) {
223
+ usages.push(...extractI18nBlock(content, filePath))
224
+ }
225
+
226
+ return usages
227
+ }
228
+
229
+ /**
230
+ * Collect all scannable files recursively
231
+ */
232
+ function collectFiles(dir: string, excludeDirs: string[], extensions: string[]): string[] {
233
+ const files: string[] = []
234
+
235
+ let entries: string[]
236
+ try {
237
+ entries = readdirSync(dir)
238
+ } catch {
239
+ return files
240
+ }
241
+
242
+ for (const entry of entries) {
243
+ const fullPath = resolve(dir, entry)
244
+
245
+ // Skip excluded directories
246
+ if (excludeDirs.some((ex) => entry === ex || fullPath.includes(`/${ex}/`))) continue
247
+
248
+ try {
249
+ const stat = statSync(fullPath)
250
+ if (stat.isDirectory()) {
251
+ files.push(...collectFiles(fullPath, excludeDirs, extensions))
252
+ } else if (extensions.includes(extname(entry))) {
253
+ files.push(fullPath)
254
+ }
255
+ } catch {
256
+ // Skip unreadable files
257
+ }
258
+ }
259
+
260
+ return files
261
+ }
262
+
263
+ /**
264
+ * Main scanner — scans the project for vue-i18n key usages
265
+ */
266
+ export function scanProject(options: {
267
+ projectRoot: string
268
+ excludeDirs?: string[]
269
+ extensions?: string[]
270
+ }): ScanResult {
271
+ const {
272
+ projectRoot,
273
+ excludeDirs = ['node_modules', 'dist', '.nuxt', '.output', '.git', 'coverage'],
274
+ extensions = ['.vue', '.ts', '.js', '.mts', '.mjs'],
275
+ } = options
276
+
277
+ const usages: KeyUsage[] = []
278
+ const errors: string[] = []
279
+
280
+ const files = collectFiles(projectRoot, excludeDirs, extensions)
281
+
282
+ for (const filePath of files) {
283
+ try {
284
+ const content = readFileSync(filePath, 'utf-8')
285
+ const relPath = relative(projectRoot, filePath)
286
+ const fileUsages = scanFile(relPath, content)
287
+ usages.push(...fileUsages)
288
+ } catch (e: any) {
289
+ errors.push(`${filePath}: ${e.message}`)
290
+ }
291
+ }
292
+
293
+ // Deduplicate by key+file+line
294
+ const seen = new Set<string>()
295
+ const deduped = usages.filter((u) => {
296
+ const sig = `${u.key}||${u.filePath}||${u.lineNumber}||${u.detectedFunction}`
297
+ if (seen.has(sig)) return false
298
+ seen.add(sig)
299
+ return true
300
+ })
301
+
302
+ return {
303
+ usages: deduped,
304
+ scannedFiles: files.map((f) => relative(projectRoot, f)),
305
+ errors,
306
+ }
307
+ }
@@ -0,0 +1,142 @@
1
+ import { translate } from '@vitalets/google-translate-api'
2
+ import type { Knex } from 'knex'
3
+
4
+ import { JobStatus, JOB_BATCH_SIZE, JOB_BATCH_DELAY_MS } from '../consts/translation-job.const'
5
+ import type { TranslationJob } from '../interfaces/translation-job.interface'
6
+
7
+ // ── In-memory job store ───────────────────────────────────────────────────────
8
+ const _jobs = new Map<string, TranslationJob>()
9
+
10
+ export function createJob(projectId: number, languageCode: string, languageName: string): TranslationJob {
11
+ const id = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
12
+ const job: TranslationJob = {
13
+ id,
14
+ status: JobStatus.RUNNING,
15
+ projectId,
16
+ languageCode,
17
+ languageName,
18
+ total: 0,
19
+ done: 0,
20
+ errors: 0,
21
+ startedAt: Date.now(),
22
+ }
23
+ _jobs.set(id, job)
24
+ return job
25
+ }
26
+
27
+ export function getJob(id: string): TranslationJob | undefined {
28
+ return _jobs.get(id)
29
+ }
30
+
31
+ // Clean up finished jobs after 10 minutes
32
+ function scheduleCleanup(id: string) {
33
+ setTimeout(() => _jobs.delete(id), 10 * 60 * 1000)
34
+ }
35
+
36
+ // ── Main runner ───────────────────────────────────────────────────────────────
37
+ export async function runTranslationJob(db: Knex, job: TranslationJob): Promise<void> {
38
+ try {
39
+ // Find source language (default lang of the project)
40
+ const sourceLang = await db('languages')
41
+ .where({ project_id: job.projectId, is_default: true })
42
+ .first()
43
+ ?? await db('languages')
44
+ .where({ project_id: job.projectId })
45
+ .whereNot({ code: job.languageCode })
46
+ .first()
47
+
48
+ if (!sourceLang) {
49
+ job.status = JobStatus.ERROR
50
+ scheduleCleanup(job.id)
51
+ return
52
+ }
53
+
54
+ // Get all source translations (keys that have a value in source language)
55
+ const sourceTranslations = await db('translation_keys as tk')
56
+ .join('translations as t', function () {
57
+ this.on('t.key_id', '=', 'tk.id')
58
+ .andOn('t.language_code', '=', db.raw('?', [sourceLang.code]))
59
+ })
60
+ .where('tk.project_id', job.projectId)
61
+ .whereNotNull('t.value')
62
+ .where('t.value', '!=', '')
63
+ .select('tk.id as key_id', 't.value as source_value')
64
+
65
+ if (!sourceTranslations.length) {
66
+ job.status = JobStatus.DONE
67
+ scheduleCleanup(job.id)
68
+ return
69
+ }
70
+
71
+ // Find which keys already have a translation in target language
72
+ const existingKeyIds = new Set(
73
+ (await db('translations')
74
+ .whereIn('key_id', sourceTranslations.map((r: any) => r.key_id))
75
+ .where('language_code', job.languageCode)
76
+ .whereNotNull('value')
77
+ .where('value', '!=', '')
78
+ .select('key_id')
79
+ ).map((r: any) => r.key_id),
80
+ )
81
+
82
+ const toTranslate = sourceTranslations.filter((r: any) => !existingKeyIds.has(r.key_id))
83
+ job.total = toTranslate.length
84
+
85
+ if (!toTranslate.length) {
86
+ job.status = JobStatus.DONE
87
+ scheduleCleanup(job.id)
88
+ return
89
+ }
90
+
91
+ const SEPARATOR = ' ||| '
92
+
93
+ for (let i = 0; i < toTranslate.length; i += JOB_BATCH_SIZE) {
94
+ const chunk = toTranslate.slice(i, i + JOB_BATCH_SIZE)
95
+ const combined = chunk.map((r: any) => r.source_value).join(SEPARATOR)
96
+
97
+ try {
98
+ const result = await translate(combined, { from: sourceLang.code, to: job.languageCode })
99
+ const translatedTexts = result.text.split(SEPARATOR)
100
+
101
+ for (let j = 0; j < chunk.length; j++) {
102
+ const value = (translatedTexts[j] || '').trim()
103
+ if (!value) continue
104
+
105
+ const existing = await db('translations')
106
+ .where({ key_id: chunk[j].key_id, language_code: job.languageCode })
107
+ .first()
108
+
109
+ if (existing) {
110
+ if (!existing.value) {
111
+ await db('translations').where({ id: existing.id })
112
+ .update({ value, status: 'draft', updated_at: db.fn.now() })
113
+ }
114
+ } else {
115
+ await db('translations').insert({
116
+ key_id: chunk[j].key_id,
117
+ language_code: job.languageCode,
118
+ value,
119
+ status: 'draft',
120
+ })
121
+ }
122
+ job.done++
123
+ }
124
+ } catch (e: any) {
125
+ console.error(`[TranslationJob] Batch failed (${job.languageCode}):`, e.message)
126
+ job.errors += chunk.length
127
+ job.done += chunk.length
128
+ }
129
+
130
+ if (i + JOB_BATCH_SIZE < toTranslate.length) {
131
+ await new Promise(r => setTimeout(r, JOB_BATCH_DELAY_MS))
132
+ }
133
+ }
134
+
135
+ job.status = JobStatus.DONE
136
+ } catch (e: any) {
137
+ console.error(`[TranslationJob] Fatal error:`, e.message)
138
+ job.status = JobStatus.ERROR
139
+ } finally {
140
+ scheduleCleanup(job.id)
141
+ }
142
+ }
@@ -0,0 +1,31 @@
1
+ import { BaseService } from './base.service'
2
+ import type { AuthUser } from '../composables/useAuth'
3
+
4
+ class AuthService extends BaseService {
5
+ async login(email: string, password: string): Promise<AuthUser> {
6
+ return this.post<AuthUser>('/api/auth/login', {
7
+ body: { email, password },
8
+ skipErrorToast: true, // login errors are handled by the page
9
+ })
10
+ }
11
+
12
+ async logout(): Promise<void> {
13
+ return this.post('/api/auth/logout', { skipDedup: true })
14
+ }
15
+
16
+ async me(): Promise<AuthUser | null> {
17
+ return this.get<AuthUser | null>('/api/auth/me', { skipErrorToast: true })
18
+ }
19
+
20
+ async changePassword(currentPassword: string, newPassword: string): Promise<void> {
21
+ return this.put('/api/auth/password', {
22
+ body: { current_password: currentPassword, new_password: newPassword },
23
+ })
24
+ }
25
+
26
+ async updateMe(data: { name?: string; email?: string }): Promise<AuthUser> {
27
+ return this.put<AuthUser>('/api/auth/me', { body: data })
28
+ }
29
+ }
30
+
31
+ export const authService = new AuthService()
@@ -0,0 +1,140 @@
1
+ import type { RequestConfig, RequestContext, ServiceHooks } from '../interfaces/commons.interface'
2
+ import type { Method } from '../types/commons.type'
3
+ import { METHODS } from '../enums/commons.enum'
4
+
5
+ // Deduplication registry shared across all service instances
6
+ const _inFlight = new Map<string, Promise<any>>()
7
+
8
+ // Refresh lock — prevents concurrent refresh attempts
9
+ let _refreshing: Promise<boolean> | null = null
10
+
11
+ export abstract class BaseService {
12
+ // Per-instance active request counter → drives `loading`
13
+ private _activeCount = 0
14
+ readonly loading = ref(false)
15
+
16
+ // Override in subclass to add lifecycle hooks
17
+ protected hooks: ServiceHooks = {}
18
+
19
+ // ── Internal helpers ────────────────────────────────────────────────────────
20
+
21
+ private _setLoading(delta: 1 | -1) {
22
+ this._activeCount = Math.max(0, this._activeCount + delta)
23
+ this.loading.value = this._activeCount > 0
24
+ }
25
+
26
+ private _dedupKey(method: Method, path: string, query?: Record<string, any>): string {
27
+ const q = query ? `:${JSON.stringify(query)}` : ''
28
+ return `${method}:${path}${q}`
29
+ }
30
+
31
+ private async _doFetch<T>(method: Method, path: string, config: RequestConfig): Promise<T> {
32
+ return $fetch<T>(path, {
33
+ method,
34
+ query: config.query,
35
+ body: config.body,
36
+ headers: config.headers,
37
+ })
38
+ }
39
+
40
+ private async _tryRefresh(): Promise<boolean> {
41
+ if (_refreshing) return _refreshing
42
+ _refreshing = $fetch('/api/auth/me')
43
+ .then(() => true)
44
+ .catch(() => false)
45
+ .finally(() => { _refreshing = null })
46
+ return _refreshing
47
+ }
48
+
49
+ private async _handleAuthFailure(): Promise<void> {
50
+ try { await $fetch('/api/auth/logout', { method: 'POST' }) } catch {}
51
+ try {
52
+ await useNuxtApp().runWithContext(() => navigateTo('/login', { replace: true }))
53
+ } catch {}
54
+ }
55
+
56
+ // ── Core request ────────────────────────────────────────────────────────────
57
+
58
+ private async _request<T>(method: Method, path: string, config: RequestConfig = {}): Promise<T> {
59
+ const dedupKey = config.skipDedup ? null : this._dedupKey(method, path, config.query)
60
+
61
+ // Return existing in-flight promise for the same key
62
+ if (dedupKey && _inFlight.has(dedupKey)) {
63
+ return _inFlight.get(dedupKey) as Promise<T>
64
+ }
65
+
66
+ const ctx: RequestContext = { method, path, config }
67
+
68
+ const execute = async (): Promise<T> => {
69
+ this._setLoading(1)
70
+ try {
71
+ await this.hooks.beforeRequest?.(ctx)
72
+
73
+ let response: T
74
+ try {
75
+ response = await this._doFetch<T>(method, path, config)
76
+ }
77
+ catch (error: any) {
78
+ const status = error?.status ?? error?.statusCode
79
+ if (status === 401) {
80
+ const sessionStillValid = await this._tryRefresh()
81
+ if (sessionStillValid) {
82
+ // Retry once after successful session re-validation
83
+ response = await this._doFetch<T>(method, path, config)
84
+ }
85
+ else {
86
+ await this._handleAuthFailure()
87
+ throw new Error('Session expirée. Veuillez vous reconnecter.')
88
+ }
89
+ }
90
+ else {
91
+ throw error
92
+ }
93
+ }
94
+
95
+ await this.hooks.afterRequest?.(ctx, response!)
96
+ return response!
97
+ }
98
+ catch (error: any) {
99
+ await this.hooks.onError?.(ctx, error)
100
+
101
+ const message = error?.data?.message ?? error?.message ?? 'Une erreur est survenue'
102
+ if (!config.skipErrorToast) {
103
+ try { useToast().add({ title: 'Erreur', description: message, color: 'error' }) }
104
+ catch {}
105
+ }
106
+ throw new Error(message)
107
+ }
108
+ finally {
109
+ this._setLoading(-1)
110
+ if (dedupKey) _inFlight.delete(dedupKey)
111
+ }
112
+ }
113
+
114
+ const promise = execute()
115
+ if (dedupKey) _inFlight.set(dedupKey, promise)
116
+ return promise
117
+ }
118
+
119
+ // ── Public HTTP methods ─────────────────────────────────────────────────────
120
+
121
+ protected get<T = void>(path: string, config?: RequestConfig): Promise<T> {
122
+ return this._request<T>(METHODS.GET, path, config)
123
+ }
124
+
125
+ protected post<T = void>(path: string, config?: RequestConfig): Promise<T> {
126
+ return this._request<T>(METHODS.POST, path, config)
127
+ }
128
+
129
+ protected put<T = void>(path: string, config?: RequestConfig): Promise<T> {
130
+ return this._request<T>(METHODS.PUT, path, config)
131
+ }
132
+
133
+ protected patch<T = void>(path: string, config?: RequestConfig): Promise<T> {
134
+ return this._request<T>(METHODS.PATCH, path, config)
135
+ }
136
+
137
+ protected delete<T = void>(path: string, config?: RequestConfig): Promise<T> {
138
+ return this._request<T>(METHODS.DELETE, path, config)
139
+ }
140
+ }
@@ -0,0 +1,10 @@
1
+ import { BaseService } from './base.service'
2
+ import type { JobStatus } from '../interfaces/job.interface'
3
+
4
+ class JobService extends BaseService {
5
+ async getJob(jobId: string): Promise<JobStatus> {
6
+ return this.get<JobStatus>(`/api/translations/job/${jobId}`)
7
+ }
8
+ }
9
+
10
+ export const jobService = new JobService()
@@ -0,0 +1,26 @@
1
+ import { BaseService } from './base.service'
2
+ import type { KeyItem, KeysQuery, KeysResponse } from '../interfaces/key.interface'
3
+
4
+ class KeyService extends BaseService {
5
+ async getKeys(query: KeysQuery): Promise<KeysResponse> {
6
+ return this.get<KeysResponse>('/api/keys', { query })
7
+ }
8
+
9
+ async getKey(id: number | string): Promise<KeyItem> {
10
+ return this.get<KeyItem>(`/api/keys/${id}`)
11
+ }
12
+
13
+ async createKey(data: { project_id: number; key: string; description?: string }): Promise<KeyItem> {
14
+ return this.post<KeyItem>('/api/keys', { body: data, skipDedup: true })
15
+ }
16
+
17
+ async updateKey(id: number | string, data: { description?: string | null }): Promise<void> {
18
+ return this.patch(`/api/keys/${id}`, { body: data })
19
+ }
20
+
21
+ async deleteKey(id: number | string): Promise<void> {
22
+ return this.delete(`/api/keys/${id}`)
23
+ }
24
+ }
25
+
26
+ export const keyService = new KeyService()
@@ -0,0 +1,26 @@
1
+ import { BaseService } from './base.service'
2
+ import type { CreateLanguagePayload, LanguageItem } from '../interfaces/languages.interface'
3
+
4
+ class LanguageService extends BaseService {
5
+ async getLanguages(projectId?: number): Promise<LanguageItem[]> {
6
+ if (!projectId) return []
7
+ return this.get<LanguageItem[]>('/api/languages', { query: { project_id: projectId } })
8
+ }
9
+
10
+ async create(data: CreateLanguagePayload): Promise<void> {
11
+ return this.post('/api/languages', { body: data, skipDedup: true })
12
+ }
13
+
14
+ async setDefault(lang: LanguageItem, projectId: number): Promise<void> {
15
+ return this.post('/api/languages', {
16
+ body: { ...lang, project_id: projectId, is_default: true },
17
+ skipDedup: true,
18
+ })
19
+ }
20
+
21
+ async remove(code: string, projectId: number): Promise<void> {
22
+ return this.delete(`/api/languages/${code}`, { query: { project_id: projectId } })
23
+ }
24
+ }
25
+
26
+ export const languageService = new LanguageService()