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,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
+ })