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,165 @@
|
|
|
1
|
+
import { resolve } from 'path'
|
|
2
|
+
import { getDb } from '../db/index'
|
|
3
|
+
import { scanProject, detectLanguages } from '../utils/scanner.uti'
|
|
4
|
+
|
|
5
|
+
export default defineEventHandler(async (event) => {
|
|
6
|
+
const body = await readBody(event)
|
|
7
|
+
const { project_id, mode = 'local', root_path: bodyRootPath, url: bodyUrl } = body
|
|
8
|
+
|
|
9
|
+
if (!project_id) throw createError({ statusCode: 400, message: 'project_id is required' })
|
|
10
|
+
|
|
11
|
+
const db = getDb()
|
|
12
|
+
const project = await db('projects').where({ id: Number(project_id) }).first()
|
|
13
|
+
if (!project) throw createError({ statusCode: 404, message: 'Project not found' })
|
|
14
|
+
if (project.is_system) throw createError({ statusCode: 403, message: 'Cannot scan system project' })
|
|
15
|
+
|
|
16
|
+
// ── URL mode: fetch locale files and import keys ───────────────────────
|
|
17
|
+
if (mode === 'url') {
|
|
18
|
+
const baseUrl = (bodyUrl || project.source_url || '').replace(/\/$/, '')
|
|
19
|
+
if (!baseUrl) throw createError({ statusCode: 400, message: 'No URL provided' })
|
|
20
|
+
|
|
21
|
+
const languages = await db('languages').where({ project_id: Number(project_id) }).select('code')
|
|
22
|
+
if (!languages.length) throw createError({ statusCode: 400, message: 'No languages configured for this project' })
|
|
23
|
+
|
|
24
|
+
const separator = project.key_separator || '.'
|
|
25
|
+
let keysAdded = 0
|
|
26
|
+
let keysFound = 0
|
|
27
|
+
|
|
28
|
+
for (const lang of languages) {
|
|
29
|
+
const url = `${baseUrl}/locale/${lang.code}.json`
|
|
30
|
+
let data: Record<string, any>
|
|
31
|
+
try {
|
|
32
|
+
const res = await fetch(url)
|
|
33
|
+
if (!res.ok) continue
|
|
34
|
+
data = await res.json()
|
|
35
|
+
} catch {
|
|
36
|
+
continue
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const flat = flattenObject(data, separator)
|
|
40
|
+
|
|
41
|
+
for (const key of Object.keys(flat)) {
|
|
42
|
+
keysFound++
|
|
43
|
+
const existing = await db('translation_keys').where({ project_id: Number(project_id), key }).first()
|
|
44
|
+
if (!existing) {
|
|
45
|
+
await db('translation_keys').insert({
|
|
46
|
+
project_id: Number(project_id),
|
|
47
|
+
key,
|
|
48
|
+
is_unused: false,
|
|
49
|
+
last_scanned_at: db.fn.now(),
|
|
50
|
+
})
|
|
51
|
+
keysAdded++
|
|
52
|
+
} else {
|
|
53
|
+
await db('translation_keys').where({ id: existing.id }).update({ is_unused: false, last_scanned_at: db.fn.now() })
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const totalKeys = await db('translation_keys').where({ project_id: Number(project_id) }).count('* as count').first()
|
|
59
|
+
return { keysImported: keysFound, keysAdded, total: Number((totalKeys as any)?.count || 0) }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ── Local mode: scan source files ─────────────────────────────────────
|
|
63
|
+
const rootPath = bodyRootPath || project.root_path
|
|
64
|
+
if (!rootPath) {
|
|
65
|
+
throw createError({ statusCode: 400, message: 'No local path provided. Select a folder or use URL mode.' })
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const settings = await db('settings').select('*')
|
|
69
|
+
const settingsMap: Record<string, string> = {}
|
|
70
|
+
for (const s of settings) settingsMap[s.key] = s.value
|
|
71
|
+
|
|
72
|
+
const excludeDirs = (settingsMap['scan_exclude'] || 'node_modules,dist,.nuxt,.output,.git')
|
|
73
|
+
.split(',').map((s) => s.trim()).filter(Boolean)
|
|
74
|
+
|
|
75
|
+
const projectRoot = resolve(rootPath)
|
|
76
|
+
|
|
77
|
+
// Auto-detect languages from locale files
|
|
78
|
+
const detectedLangs = detectLanguages({ projectRoot, localesPath: project.locales_path })
|
|
79
|
+
let langsAdded = 0
|
|
80
|
+
for (const lang of detectedLangs) {
|
|
81
|
+
const existing = await db('languages').where({ project_id: Number(project_id), code: lang.code }).first()
|
|
82
|
+
if (!existing) {
|
|
83
|
+
await db('languages').insert({ project_id: Number(project_id), code: lang.code, name: lang.name, is_default: false })
|
|
84
|
+
langsAdded++
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Scan source files
|
|
89
|
+
const { usages, scannedFiles, errors } = scanProject({
|
|
90
|
+
projectRoot,
|
|
91
|
+
excludeDirs,
|
|
92
|
+
extensions: ['.vue', '.ts', '.js', '.mts', '.mjs'],
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
const keyMap = new Map<string, typeof usages>()
|
|
96
|
+
for (const usage of usages) {
|
|
97
|
+
const existing = keyMap.get(usage.key) || []
|
|
98
|
+
existing.push(usage)
|
|
99
|
+
keyMap.set(usage.key, existing)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const now = db.fn.now()
|
|
103
|
+
let keysAdded = 0
|
|
104
|
+
let keysFound = 0
|
|
105
|
+
|
|
106
|
+
const existingKeys = await db('translation_keys').where({ project_id: Number(project_id) }).select('id', 'key')
|
|
107
|
+
const existingKeyMap = new Map<string, number>()
|
|
108
|
+
for (const k of existingKeys) existingKeyMap.set(k.key, k.id)
|
|
109
|
+
|
|
110
|
+
if (existingKeys.length > 0) {
|
|
111
|
+
await db('key_usages').whereIn('key_id', existingKeys.map(k => k.id)).delete()
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
for (const [key, keyUsages] of keyMap.entries()) {
|
|
115
|
+
let keyId = existingKeyMap.get(key)
|
|
116
|
+
if (!keyId) {
|
|
117
|
+
const [id] = await db('translation_keys').insert({ project_id: Number(project_id), key, is_unused: false, last_scanned_at: now })
|
|
118
|
+
keyId = id
|
|
119
|
+
keysAdded++
|
|
120
|
+
} else {
|
|
121
|
+
await db('translation_keys').where({ id: keyId }).update({ is_unused: false, last_scanned_at: now })
|
|
122
|
+
}
|
|
123
|
+
keysFound++
|
|
124
|
+
|
|
125
|
+
await db('key_usages').insert(keyUsages.map(u => ({
|
|
126
|
+
key_id: keyId,
|
|
127
|
+
file_path: u.filePath,
|
|
128
|
+
line_number: u.lineNumber,
|
|
129
|
+
detected_function: u.detectedFunction,
|
|
130
|
+
scanned_at: now,
|
|
131
|
+
})))
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const foundKeys = new Set(keyMap.keys())
|
|
135
|
+
const unusedIds = existingKeys.filter(k => !foundKeys.has(k.key)).map(k => k.id)
|
|
136
|
+
if (unusedIds.length > 0) {
|
|
137
|
+
await db('translation_keys').whereIn('id', unusedIds).update({ is_unused: true, last_scanned_at: now })
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const totalKeys = await db('translation_keys').where({ project_id: Number(project_id) }).count('* as count').first()
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
keysFound,
|
|
144
|
+
keysAdded,
|
|
145
|
+
unusedKeys: unusedIds.length,
|
|
146
|
+
scannedFiles: scannedFiles.length,
|
|
147
|
+
total: Number((totalKeys as any)?.count || 0),
|
|
148
|
+
langsDetected: detectedLangs.length,
|
|
149
|
+
langsAdded,
|
|
150
|
+
errors: errors.slice(0, 10),
|
|
151
|
+
}
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
function flattenObject(obj: Record<string, any>, separator: string, prefix = ''): Record<string, string> {
|
|
155
|
+
const result: Record<string, string> = {}
|
|
156
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
157
|
+
const fullKey = prefix ? `${prefix}${separator}${key}` : key
|
|
158
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
159
|
+
Object.assign(result, flattenObject(value, separator, fullKey))
|
|
160
|
+
} else {
|
|
161
|
+
result[fullKey] = String(value ?? '')
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return result
|
|
165
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { getDb } from '../../db/index'
|
|
2
|
+
|
|
3
|
+
export default defineEventHandler(async () => {
|
|
4
|
+
const db = getDb()
|
|
5
|
+
const settings = await db('settings').select('*')
|
|
6
|
+
const map: Record<string, string> = {}
|
|
7
|
+
for (const s of settings) map[s.key] = s.value
|
|
8
|
+
return map
|
|
9
|
+
})
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { getDb } from '../../db/index'
|
|
2
|
+
|
|
3
|
+
export default defineEventHandler(async (event) => {
|
|
4
|
+
const body = await readBody(event)
|
|
5
|
+
const db = getDb()
|
|
6
|
+
|
|
7
|
+
for (const [key, value] of Object.entries(body)) {
|
|
8
|
+
const existing = await db('settings').where({ key }).first()
|
|
9
|
+
if (existing) {
|
|
10
|
+
await db('settings').where({ key }).update({ value: String(value), updated_at: db.fn.now() })
|
|
11
|
+
} else {
|
|
12
|
+
await db('settings').insert({ key, value: String(value) })
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const settings = await db('settings').select('*')
|
|
17
|
+
const map: Record<string, string> = {}
|
|
18
|
+
for (const s of settings) map[s.key] = s.value
|
|
19
|
+
return map
|
|
20
|
+
})
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import bcrypt from 'bcryptjs'
|
|
2
|
+
import { getDb } from '../db/index'
|
|
3
|
+
import { getSession } from '../utils/auth.util'
|
|
4
|
+
|
|
5
|
+
export default defineEventHandler(async (event) => {
|
|
6
|
+
const db = getDb()
|
|
7
|
+
|
|
8
|
+
// Only allowed when no users exist yet
|
|
9
|
+
const count = await db('users').count('* as count').first()
|
|
10
|
+
if (Number((count as any)?.count || 0) > 0) {
|
|
11
|
+
throw createError({ statusCode: 403, message: 'Le setup a déjà été effectué' })
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const { name, email, password } = await readBody(event)
|
|
15
|
+
|
|
16
|
+
if (!name || !email || !password) {
|
|
17
|
+
throw createError({ statusCode: 400, message: 'Nom, email et mot de passe requis' })
|
|
18
|
+
}
|
|
19
|
+
if (password.length < 8) {
|
|
20
|
+
throw createError({ statusCode: 400, message: 'Le mot de passe doit contenir au moins 8 caractères' })
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const hash = await bcrypt.hash(password, 12)
|
|
24
|
+
const [id] = await db('users').insert({
|
|
25
|
+
name: name.trim(),
|
|
26
|
+
email: email.toLowerCase().trim(),
|
|
27
|
+
password_hash: hash,
|
|
28
|
+
is_super_admin: true,
|
|
29
|
+
is_active: true,
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
// Auto-login
|
|
33
|
+
const session = await getSession(event)
|
|
34
|
+
await session.update({ userId: id })
|
|
35
|
+
|
|
36
|
+
console.log(`[i18n-dashboard] Super admin créé : ${email}`)
|
|
37
|
+
|
|
38
|
+
return { success: true, id }
|
|
39
|
+
})
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { getDb } from '../../db/index'
|
|
2
|
+
import { requireAuth } from '../../utils/auth.util'
|
|
3
|
+
|
|
4
|
+
export default defineEventHandler(async (event) => {
|
|
5
|
+
const user = await requireAuth(event)
|
|
6
|
+
const db = getDb()
|
|
7
|
+
|
|
8
|
+
// Determine accessible project IDs
|
|
9
|
+
let projectIds: number[]
|
|
10
|
+
|
|
11
|
+
if (user.is_super_admin) {
|
|
12
|
+
const rows = await db('projects').select('id')
|
|
13
|
+
projectIds = rows.map((r: any) => r.id)
|
|
14
|
+
} else {
|
|
15
|
+
// Check for a global role (project_id IS NULL) → access to all non-system projects
|
|
16
|
+
const globalRole = await db('user_project_roles')
|
|
17
|
+
.where({ user_id: user.id })
|
|
18
|
+
.whereNull('project_id')
|
|
19
|
+
.first()
|
|
20
|
+
|
|
21
|
+
if (globalRole) {
|
|
22
|
+
const rows = await db('projects').select('id')
|
|
23
|
+
projectIds = rows.map((r: any) => r.id)
|
|
24
|
+
} else {
|
|
25
|
+
const specific = await db('user_project_roles')
|
|
26
|
+
.where({ user_id: user.id })
|
|
27
|
+
.whereNotNull('project_id')
|
|
28
|
+
.select('project_id')
|
|
29
|
+
projectIds = specific.map((r: any) => r.project_id)
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (projectIds.length === 0) {
|
|
34
|
+
return { totalKeys: 0, unusedKeys: 0, languages: [], recentActivity: [] }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Aggregate totalKeys and unusedKeys
|
|
38
|
+
const totalKeysRow = await db('translation_keys')
|
|
39
|
+
.whereIn('project_id', projectIds)
|
|
40
|
+
.count('* as count')
|
|
41
|
+
.first()
|
|
42
|
+
const unusedKeysRow = await db('translation_keys')
|
|
43
|
+
.whereIn('project_id', projectIds)
|
|
44
|
+
.where({ is_unused: true })
|
|
45
|
+
.count('* as count')
|
|
46
|
+
.first()
|
|
47
|
+
|
|
48
|
+
const totalKeys = Number((totalKeysRow as any)?.count || 0)
|
|
49
|
+
const unusedKeys = Number((unusedKeysRow as any)?.count || 0)
|
|
50
|
+
|
|
51
|
+
// Languages: aggregate by code across all accessible projects
|
|
52
|
+
const allLanguages = await db('languages')
|
|
53
|
+
.whereIn('project_id', projectIds)
|
|
54
|
+
.select('*')
|
|
55
|
+
.orderBy('is_default', 'desc')
|
|
56
|
+
.orderBy('code', 'asc')
|
|
57
|
+
|
|
58
|
+
// Group language stats by code
|
|
59
|
+
const langMap = new Map<string, { code: string; name: string; is_default: boolean; total: number; translated: number; draft: number; reviewed: number; approved: number }>()
|
|
60
|
+
|
|
61
|
+
for (const lang of allLanguages) {
|
|
62
|
+
const existing = langMap.get(lang.code)
|
|
63
|
+
|
|
64
|
+
const translatedRow = await db('translations as t')
|
|
65
|
+
.join('translation_keys as k', 't.key_id', 'k.id')
|
|
66
|
+
.whereIn('k.project_id', projectIds)
|
|
67
|
+
.where('t.language_code', lang.code)
|
|
68
|
+
.whereNotNull('t.value')
|
|
69
|
+
.where('t.value', '!=', '')
|
|
70
|
+
.count('* as count')
|
|
71
|
+
.first()
|
|
72
|
+
|
|
73
|
+
const byStatus = await db('translations as t')
|
|
74
|
+
.join('translation_keys as k', 't.key_id', 'k.id')
|
|
75
|
+
.whereIn('k.project_id', projectIds)
|
|
76
|
+
.where('t.language_code', lang.code)
|
|
77
|
+
.whereNotNull('t.value')
|
|
78
|
+
.where('t.value', '!=', '')
|
|
79
|
+
.groupBy('t.status')
|
|
80
|
+
.select('t.status')
|
|
81
|
+
.count('* as count')
|
|
82
|
+
|
|
83
|
+
const statusMap: Record<string, number> = { draft: 0, reviewed: 0, approved: 0 }
|
|
84
|
+
for (const row of byStatus) {
|
|
85
|
+
statusMap[(row as any).status || 'draft'] = Number((row as any).count)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const translatedCount = Number((translatedRow as any)?.count || 0)
|
|
89
|
+
|
|
90
|
+
if (!existing) {
|
|
91
|
+
langMap.set(lang.code, {
|
|
92
|
+
code: lang.code,
|
|
93
|
+
name: lang.name,
|
|
94
|
+
is_default: !!lang.is_default,
|
|
95
|
+
total: totalKeys,
|
|
96
|
+
translated: translatedCount,
|
|
97
|
+
draft: statusMap.draft,
|
|
98
|
+
reviewed: statusMap.reviewed,
|
|
99
|
+
approved: statusMap.approved,
|
|
100
|
+
})
|
|
101
|
+
}
|
|
102
|
+
// If already exists (same lang code in multiple projects), skip — already aggregated above per-code
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const langStats = Array.from(langMap.values()).map(l => ({
|
|
106
|
+
...l,
|
|
107
|
+
missing: l.total - l.translated,
|
|
108
|
+
coverage: l.total > 0 ? Math.round((l.translated / l.total) * 100) : 0,
|
|
109
|
+
}))
|
|
110
|
+
|
|
111
|
+
// Recent activity across all accessible projects
|
|
112
|
+
const recentActivity = await db('translation_history as h')
|
|
113
|
+
.join('translations as t', 'h.translation_id', 't.id')
|
|
114
|
+
.join('translation_keys as k', 't.key_id', 'k.id')
|
|
115
|
+
.whereIn('k.project_id', projectIds)
|
|
116
|
+
.orderBy('h.changed_at', 'desc')
|
|
117
|
+
.limit(20)
|
|
118
|
+
.select('h.id', 'h.old_value', 'h.new_value', 'h.changed_by', 'h.changed_at', 'k.key', 't.language_code')
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
totalKeys,
|
|
122
|
+
unusedKeys,
|
|
123
|
+
languages: langStats,
|
|
124
|
+
recentActivity,
|
|
125
|
+
}
|
|
126
|
+
})
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { getDb } from '../db/index'
|
|
2
|
+
|
|
3
|
+
export default defineEventHandler(async (event) => {
|
|
4
|
+
const { project_id } = getQuery(event)
|
|
5
|
+
if (!project_id) return { totalKeys: 0, unusedKeys: 0, languages: [], recentActivity: [] }
|
|
6
|
+
|
|
7
|
+
const db = getDb()
|
|
8
|
+
const pid = Number(project_id)
|
|
9
|
+
|
|
10
|
+
const languages = await db('languages').where({ project_id: pid }).select('*').orderBy('is_default', 'desc').orderBy('name', 'asc')
|
|
11
|
+
const totalKeys = await db('translation_keys').where({ project_id: pid }).count('* as count').first()
|
|
12
|
+
const unusedKeys = await db('translation_keys').where({ project_id: pid, is_unused: true }).count('* as count').first()
|
|
13
|
+
|
|
14
|
+
const langStats = await Promise.all(
|
|
15
|
+
languages.map(async (lang: any) => {
|
|
16
|
+
const total = Number((totalKeys as any)?.count || 0)
|
|
17
|
+
|
|
18
|
+
const translated = await db('translations as t')
|
|
19
|
+
.join('translation_keys as k', 't.key_id', 'k.id')
|
|
20
|
+
.where('k.project_id', pid)
|
|
21
|
+
.where('t.language_code', lang.code)
|
|
22
|
+
.whereNotNull('t.value')
|
|
23
|
+
.where('t.value', '!=', '')
|
|
24
|
+
.count('* as count')
|
|
25
|
+
.first()
|
|
26
|
+
|
|
27
|
+
const byStatus = await db('translations as t')
|
|
28
|
+
.join('translation_keys as k', 't.key_id', 'k.id')
|
|
29
|
+
.where('k.project_id', pid)
|
|
30
|
+
.where('t.language_code', lang.code)
|
|
31
|
+
.whereNotNull('t.value')
|
|
32
|
+
.where('t.value', '!=', '')
|
|
33
|
+
.groupBy('t.status')
|
|
34
|
+
.select('t.status')
|
|
35
|
+
.count('* as count')
|
|
36
|
+
|
|
37
|
+
const statusMap: Record<string, number> = { draft: 0, reviewed: 0, approved: 0 }
|
|
38
|
+
for (const row of byStatus) {
|
|
39
|
+
statusMap[(row as any).status || 'draft'] = Number((row as any).count)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const translatedCount = Number((translated as any)?.count || 0)
|
|
43
|
+
return {
|
|
44
|
+
...lang,
|
|
45
|
+
total,
|
|
46
|
+
translated: translatedCount,
|
|
47
|
+
missing: total - translatedCount,
|
|
48
|
+
draft: statusMap.draft,
|
|
49
|
+
reviewed: statusMap.reviewed,
|
|
50
|
+
approved: statusMap.approved,
|
|
51
|
+
coverage: total > 0 ? Math.round((translatedCount / total) * 100) : 0,
|
|
52
|
+
}
|
|
53
|
+
}),
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
const recentActivity = await db('translation_history as h')
|
|
57
|
+
.join('translations as t', 'h.translation_id', 't.id')
|
|
58
|
+
.join('translation_keys as k', 't.key_id', 'k.id')
|
|
59
|
+
.where('k.project_id', pid)
|
|
60
|
+
.orderBy('h.changed_at', 'desc')
|
|
61
|
+
.limit(15)
|
|
62
|
+
.select('h.id', 'h.old_value', 'h.new_value', 'h.changed_by', 'h.changed_at', 'k.key', 't.language_code')
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
totalKeys: Number((totalKeys as any)?.count || 0),
|
|
66
|
+
unusedKeys: Number((unusedKeys as any)?.count || 0),
|
|
67
|
+
languages: langStats,
|
|
68
|
+
recentActivity,
|
|
69
|
+
}
|
|
70
|
+
})
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { resolve, extname, basename } from 'path'
|
|
2
|
+
import { readdirSync, readFileSync, existsSync } from 'fs'
|
|
3
|
+
import { getDb } from '../db/index'
|
|
4
|
+
|
|
5
|
+
function flattenObject(obj: Record<string, any>, separator: string, prefix = ''): Record<string, string> {
|
|
6
|
+
const result: Record<string, string> = {}
|
|
7
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
8
|
+
const fullKey = prefix ? `${prefix}${separator}${key}` : key
|
|
9
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
10
|
+
Object.assign(result, flattenObject(value, separator, fullKey))
|
|
11
|
+
} else {
|
|
12
|
+
result[fullKey] = String(value ?? '')
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
return result
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function fetchRemoteLocale(sourceUrl: string, langCode: string): Promise<Record<string, string> | null> {
|
|
19
|
+
const url = `${sourceUrl.replace(/\/$/, '')}/locale/${langCode}.json`
|
|
20
|
+
try {
|
|
21
|
+
const res = await fetch(url)
|
|
22
|
+
if (!res.ok) return null
|
|
23
|
+
return await res.json()
|
|
24
|
+
} catch {
|
|
25
|
+
return null
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export default defineEventHandler(async (event) => {
|
|
30
|
+
const body = await readBody(event)
|
|
31
|
+
const { project_id } = body
|
|
32
|
+
|
|
33
|
+
if (!project_id) throw createError({ statusCode: 400, message: 'project_id is required' })
|
|
34
|
+
|
|
35
|
+
const db = getDb()
|
|
36
|
+
|
|
37
|
+
const project = await db('projects').where({ id: Number(project_id) }).first()
|
|
38
|
+
if (!project) throw createError({ statusCode: 404, message: 'Project not found' })
|
|
39
|
+
if (project.is_system) throw createError({ statusCode: 403, message: 'Le projet Dashboard UI ne peut pas être synchronisé.' })
|
|
40
|
+
|
|
41
|
+
const hasLocalPath = !!project.root_path
|
|
42
|
+
const hasRemoteUrl = !!project.source_url
|
|
43
|
+
|
|
44
|
+
if (!hasLocalPath && !hasRemoteUrl) {
|
|
45
|
+
throw createError({
|
|
46
|
+
statusCode: 400,
|
|
47
|
+
message: 'Ce projet n\'a ni chemin local ni URL distante configuré. Configurez au moins l\'un des deux pour synchroniser.',
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const separator = project.key_separator || '.'
|
|
52
|
+
let added = 0
|
|
53
|
+
let updated = 0
|
|
54
|
+
let syncedFiles: string[] = []
|
|
55
|
+
|
|
56
|
+
// ── Local filesystem sync ──────────────────────────────────────────────
|
|
57
|
+
if (hasLocalPath) {
|
|
58
|
+
const absoluteLocalesPath = resolve(project.root_path, project.locales_path)
|
|
59
|
+
|
|
60
|
+
if (!existsSync(absoluteLocalesPath)) {
|
|
61
|
+
throw createError({
|
|
62
|
+
statusCode: 404,
|
|
63
|
+
message: `Locales directory not found: ${absoluteLocalesPath}`,
|
|
64
|
+
})
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const files = readdirSync(absoluteLocalesPath)
|
|
68
|
+
const jsonFiles = files.filter((f) => extname(f) === '.json')
|
|
69
|
+
|
|
70
|
+
if (jsonFiles.length === 0) {
|
|
71
|
+
return { added: 0, updated: 0, total: 0, message: 'No JSON locale files found' }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
syncedFiles = jsonFiles
|
|
75
|
+
|
|
76
|
+
for (const file of jsonFiles) {
|
|
77
|
+
const langCode = basename(file, '.json')
|
|
78
|
+
const filePath = resolve(absoluteLocalesPath, file)
|
|
79
|
+
|
|
80
|
+
const existingLang = await db('languages').where({ project_id: Number(project_id), code: langCode }).first()
|
|
81
|
+
if (!existingLang) {
|
|
82
|
+
await db('languages').insert({
|
|
83
|
+
project_id: Number(project_id),
|
|
84
|
+
code: langCode,
|
|
85
|
+
name: langCode.toUpperCase(),
|
|
86
|
+
is_default: false,
|
|
87
|
+
})
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const raw = JSON.parse(readFileSync(filePath, 'utf-8'))
|
|
91
|
+
const flattened = flattenObject(raw, separator)
|
|
92
|
+
|
|
93
|
+
const result = await upsertTranslations(db, Number(project_id), langCode, flattened)
|
|
94
|
+
added += result.added
|
|
95
|
+
updated += result.updated
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
// ── Remote URL sync ────────────────────────────────────────────────────
|
|
99
|
+
else if (hasRemoteUrl) {
|
|
100
|
+
const languages = await db('languages').where({ project_id: Number(project_id) }).select('code')
|
|
101
|
+
|
|
102
|
+
if (languages.length === 0) {
|
|
103
|
+
return {
|
|
104
|
+
added: 0,
|
|
105
|
+
updated: 0,
|
|
106
|
+
total: 0,
|
|
107
|
+
message: 'Aucune langue configurée. Ajoutez des langues avant de synchroniser depuis une URL distante.',
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
for (const lang of languages) {
|
|
112
|
+
const remoteData = await fetchRemoteLocale(project.source_url, lang.code)
|
|
113
|
+
if (!remoteData) continue
|
|
114
|
+
|
|
115
|
+
const flattened = flattenObject(remoteData, separator)
|
|
116
|
+
const result = await upsertTranslations(db, Number(project_id), lang.code, flattened)
|
|
117
|
+
added += result.added
|
|
118
|
+
updated += result.updated
|
|
119
|
+
syncedFiles.push(`${lang.code}.json (remote)`)
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const total = await db('translation_keys').where({ project_id: Number(project_id) }).count('* as count').first()
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
added,
|
|
127
|
+
updated,
|
|
128
|
+
total: Number((total as any)?.count || 0),
|
|
129
|
+
files: syncedFiles,
|
|
130
|
+
}
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
async function upsertTranslations(
|
|
134
|
+
db: any,
|
|
135
|
+
projectId: number,
|
|
136
|
+
langCode: string,
|
|
137
|
+
flattened: Record<string, string>,
|
|
138
|
+
): Promise<{ added: number; updated: number }> {
|
|
139
|
+
let added = 0
|
|
140
|
+
let updated = 0
|
|
141
|
+
|
|
142
|
+
for (const [key, value] of Object.entries(flattened)) {
|
|
143
|
+
let keyRecord = await db('translation_keys').where({ project_id: projectId, key }).first()
|
|
144
|
+
if (!keyRecord) {
|
|
145
|
+
const [id] = await db('translation_keys').insert({ project_id: projectId, key })
|
|
146
|
+
keyRecord = { id }
|
|
147
|
+
added++
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const existing = await db('translations').where({ key_id: keyRecord.id, language_code: langCode }).first()
|
|
151
|
+
if (existing) {
|
|
152
|
+
if (existing.value !== value) {
|
|
153
|
+
await db('translation_history').insert({
|
|
154
|
+
translation_id: existing.id,
|
|
155
|
+
old_value: existing.value,
|
|
156
|
+
new_value: value,
|
|
157
|
+
changed_by: 'sync',
|
|
158
|
+
})
|
|
159
|
+
await db('translations').where({ id: existing.id }).update({ value, updated_at: db.fn.now() })
|
|
160
|
+
updated++
|
|
161
|
+
}
|
|
162
|
+
} else {
|
|
163
|
+
const [id] = await db('translations').insert({
|
|
164
|
+
key_id: keyRecord.id,
|
|
165
|
+
language_code: langCode,
|
|
166
|
+
value,
|
|
167
|
+
status: 'draft',
|
|
168
|
+
})
|
|
169
|
+
await db('translation_history').insert({
|
|
170
|
+
translation_id: id,
|
|
171
|
+
old_value: null,
|
|
172
|
+
new_value: value,
|
|
173
|
+
changed_by: 'sync',
|
|
174
|
+
})
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return { added, updated }
|
|
179
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { translate } from '@vitalets/google-translate-api'
|
|
2
|
+
import { getDb } from '../db/index'
|
|
3
|
+
|
|
4
|
+
export default defineEventHandler(async (event) => {
|
|
5
|
+
const body = await readBody(event)
|
|
6
|
+
const { text, from, to, key_id, language_code } = body
|
|
7
|
+
|
|
8
|
+
if (!text || !to) {
|
|
9
|
+
throw createError({ statusCode: 400, message: 'text and to are required' })
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
try {
|
|
13
|
+
const result = await translate(text, { from: from || 'auto', to })
|
|
14
|
+
const translatedText = result.text
|
|
15
|
+
|
|
16
|
+
// If key_id and language_code provided, save the translation
|
|
17
|
+
if (key_id && language_code) {
|
|
18
|
+
const db = getDb()
|
|
19
|
+
const existing = await db('translations').where({ key_id: Number(key_id), language_code }).first()
|
|
20
|
+
|
|
21
|
+
if (existing) {
|
|
22
|
+
if (existing.value !== translatedText) {
|
|
23
|
+
await db('translation_history').insert({
|
|
24
|
+
translation_id: existing.id,
|
|
25
|
+
old_value: existing.value,
|
|
26
|
+
new_value: translatedText,
|
|
27
|
+
changed_by: 'google-translate',
|
|
28
|
+
})
|
|
29
|
+
await db('translations')
|
|
30
|
+
.where({ id: existing.id })
|
|
31
|
+
.update({ value: translatedText, updated_at: db.fn.now() })
|
|
32
|
+
}
|
|
33
|
+
} else {
|
|
34
|
+
const [id] = await db('translations').insert({
|
|
35
|
+
key_id: Number(key_id),
|
|
36
|
+
language_code,
|
|
37
|
+
value: translatedText,
|
|
38
|
+
})
|
|
39
|
+
await db('translation_history').insert({
|
|
40
|
+
translation_id: id,
|
|
41
|
+
old_value: null,
|
|
42
|
+
new_value: translatedText,
|
|
43
|
+
changed_by: 'google-translate',
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return { text: translatedText }
|
|
49
|
+
} catch (err: any) {
|
|
50
|
+
throw createError({ statusCode: 500, message: `Translation failed: ${err.message}` })
|
|
51
|
+
}
|
|
52
|
+
})
|