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.
- package/LICENSE +21 -0
- package/README.md +715 -0
- package/app.vue +8 -0
- package/assets/css/main.css +21 -0
- package/assets/locales/en.json +380 -0
- package/bin/cli.mjs +279 -0
- package/components/LinkedKeyPicker.vue +135 -0
- package/components/PathPicker.vue +153 -0
- package/components/PluralEditor.vue +295 -0
- package/components/ScanModal.vue +153 -0
- package/components/TranslationHistoryModal.vue +66 -0
- package/components/TranslationRow.vue +541 -0
- package/components/dashboard/WidgetConfigModal.vue +121 -0
- package/components/dashboard/WidgetGrid.vue +190 -0
- package/components/dashboard/WidgetPicker.vue +75 -0
- package/components/dashboard/widgets/ActivityWidget.vue +109 -0
- package/components/dashboard/widgets/LanguagesCoverageWidget.vue +104 -0
- package/components/dashboard/widgets/ProjectsWidget.vue +77 -0
- package/components/dashboard/widgets/ReviewWidget.vue +150 -0
- package/components/dashboard/widgets/StatWidget.vue +133 -0
- package/composables/useAuth.ts +72 -0
- package/composables/useConfig.ts +14 -0
- package/composables/useDashboard.ts +89 -0
- package/composables/useFormats.ts +100 -0
- package/composables/useKeys.ts +231 -0
- package/composables/useLanguages.ts +221 -0
- package/composables/useProfile.ts +76 -0
- package/composables/useProject.ts +180 -0
- package/composables/useReview.ts +94 -0
- package/composables/useSettings.ts +30 -0
- package/composables/useStats.ts +16 -0
- package/composables/useT.ts +38 -0
- package/composables/useUsers.ts +101 -0
- package/composables/useWidgetData.ts +50 -0
- package/consts/commons.const.ts +6 -0
- package/consts/dashboard.const.ts +94 -0
- package/consts/languages.const.ts +223 -0
- package/enums/commons.enum.ts +7 -0
- package/i18n-dashboard.config.example.js +40 -0
- package/interfaces/commons.interface.ts +23 -0
- package/interfaces/job.interface.ts +10 -0
- package/interfaces/key.interface.ts +39 -0
- package/interfaces/languages.interface.ts +23 -0
- package/interfaces/project.interface.ts +9 -0
- package/interfaces/scan.interface.ts +12 -0
- package/interfaces/settings.interface.ts +4 -0
- package/interfaces/stat.interface.ts +30 -0
- package/interfaces/translation.interface.ts +11 -0
- package/interfaces/user.interface.ts +24 -0
- package/layouts/auth.vue +5 -0
- package/layouts/default.vue +327 -0
- package/middleware/auth.global.ts +26 -0
- package/nuxt.config.ts +66 -0
- package/package.json +89 -0
- package/pages/index.vue +5 -0
- package/pages/login.vue +74 -0
- package/pages/onboarding.vue +563 -0
- package/pages/projects/[id]/formats/datetime.vue +240 -0
- package/pages/projects/[id]/formats/modifiers.vue +194 -0
- package/pages/projects/[id]/formats/number.vue +250 -0
- package/pages/projects/[id]/index.vue +182 -0
- package/pages/projects/[id]/languages.vue +537 -0
- package/pages/projects/[id]/review.vue +109 -0
- package/pages/projects/[id]/settings.vue +515 -0
- package/pages/projects/[id]/translations/[keyId].vue +642 -0
- package/pages/projects/[id]/translations/index.vue +250 -0
- package/pages/projects/[id]/users.vue +276 -0
- package/pages/projects/index.vue +334 -0
- package/pages/users/[id]/profile.vue +421 -0
- package/pages/users/index.vue +345 -0
- package/plugins/loading.client.ts +3 -0
- package/plugins/ui-i18n.ts +6 -0
- package/server/api/auth/login.post.ts +28 -0
- package/server/api/auth/logout.post.ts +7 -0
- package/server/api/auth/me.get.ts +11 -0
- package/server/api/auth/me.put.ts +31 -0
- package/server/api/auth/password.put.ts +27 -0
- package/server/api/auth/status.get.ts +16 -0
- package/server/api/config.get.ts +10 -0
- package/server/api/dashboard/layout.get.ts +18 -0
- package/server/api/dashboard/layout.post.ts +18 -0
- package/server/api/db-config.get.ts +44 -0
- package/server/api/db-config.post.ts +73 -0
- package/server/api/export.get.ts +64 -0
- package/server/api/formats/datetime/[id].delete.ts +8 -0
- package/server/api/formats/datetime/[id].put.ts +15 -0
- package/server/api/formats/datetime.get.ts +11 -0
- package/server/api/formats/datetime.post.ts +16 -0
- package/server/api/formats/modifiers/[id].delete.ts +8 -0
- package/server/api/formats/modifiers/[id].put.ts +10 -0
- package/server/api/formats/modifiers.get.ts +10 -0
- package/server/api/formats/modifiers.post.ts +14 -0
- package/server/api/formats/number/[id].delete.ts +8 -0
- package/server/api/formats/number/[id].put.ts +15 -0
- package/server/api/formats/number.get.ts +11 -0
- package/server/api/formats/number.post.ts +16 -0
- package/server/api/formats/snippet.get.ts +87 -0
- package/server/api/fs/browse.get.ts +50 -0
- package/server/api/history/[translationId].get.ts +13 -0
- package/server/api/keys/[id].delete.ts +14 -0
- package/server/api/keys/[id].get.ts +41 -0
- package/server/api/keys/[id].patch.ts +20 -0
- package/server/api/keys/index.get.ts +98 -0
- package/server/api/keys/index.post.ts +17 -0
- package/server/api/languages/[code].delete.ts +15 -0
- package/server/api/languages/[id].put.ts +24 -0
- package/server/api/languages/index.get.ts +13 -0
- package/server/api/languages/index.post.ts +42 -0
- package/server/api/onboarding.post.ts +56 -0
- package/server/api/profile.get.ts +81 -0
- package/server/api/project-snapshot.get.ts +73 -0
- package/server/api/project-snapshot.post.ts +160 -0
- package/server/api/projects/[id].delete.ts +13 -0
- package/server/api/projects/[id].put.ts +40 -0
- package/server/api/projects/index.get.ts +19 -0
- package/server/api/projects/index.post.ts +34 -0
- package/server/api/scan.post.ts +165 -0
- package/server/api/settings/index.get.ts +9 -0
- package/server/api/settings/index.post.ts +20 -0
- package/server/api/setup.post.ts +39 -0
- package/server/api/stats/global.get.ts +126 -0
- package/server/api/stats.get.ts +70 -0
- package/server/api/sync.post.ts +179 -0
- package/server/api/translate.post.ts +52 -0
- package/server/api/translations/batch-translate.post.ts +121 -0
- package/server/api/translations/bulk-status.post.ts +24 -0
- package/server/api/translations/index.post.ts +62 -0
- package/server/api/translations/job/[id].get.ts +23 -0
- package/server/api/translations/status.post.ts +30 -0
- package/server/api/translations/translate-all.post.ts +18 -0
- package/server/api/ui-locale.get.ts +39 -0
- package/server/api/users/[id]/profile.get.ts +107 -0
- package/server/api/users/[id]/roles.put.ts +67 -0
- package/server/api/users/[id].delete.ts +36 -0
- package/server/api/users/[id].put.ts +43 -0
- package/server/api/users/index.get.ts +49 -0
- package/server/api/users/index.post.ts +89 -0
- package/server/consts/auto-translate.const.ts +2 -0
- package/server/consts/commons.const.ts +10 -0
- package/server/consts/db.const.ts +3 -0
- package/server/consts/scanner.const.ts +4 -0
- package/server/consts/translation-job.const.ts +8 -0
- package/server/db/index.ts +672 -0
- package/server/enums/auth.enum.ts +5 -0
- package/server/enums/translation.enum.ts +6 -0
- package/server/interfaces/profile.interface.ts +48 -0
- package/server/interfaces/project-config.interface.ts +9 -0
- package/server/interfaces/scanner.interface.ts +18 -0
- package/server/interfaces/translation-job.interface.ts +13 -0
- package/server/middleware/auth.ts +32 -0
- package/server/plugins/db.ts +6 -0
- package/server/routes/locale/[lang].get.ts +179 -0
- package/server/types/auth.type.ts +3 -0
- package/server/utils/auth.util.ts +89 -0
- package/server/utils/auto-translate.util.ts +112 -0
- package/server/utils/lang-api.util.ts +24 -0
- package/server/utils/mailer.util.ts +80 -0
- package/server/utils/project-config.util.ts +37 -0
- package/server/utils/scanner.uti.ts +307 -0
- package/server/utils/translation-job.util.ts +142 -0
- package/services/auth.service.ts +31 -0
- package/services/base.service.ts +140 -0
- package/services/job.service.ts +10 -0
- package/services/key.service.ts +26 -0
- package/services/language.service.ts +26 -0
- package/services/profile.service.ts +14 -0
- package/services/project.service.ts +23 -0
- package/services/scan.service.ts +14 -0
- package/services/settings.service.ts +14 -0
- package/services/stats.service.ts +11 -0
- package/services/translation.service.ts +36 -0
- package/services/user.service.ts +28 -0
- package/tsconfig.json +3 -0
- package/types/commons.type.ts +3 -0
- package/types/dashboard.type.ts +26 -0
- 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()
|