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,42 @@
1
+ import { getDb } from '../../db/index'
2
+ import { autoTranslateUtil } from '../../utils/auto-translate.util'
3
+
4
+ export default defineEventHandler(async (event) => {
5
+ const body = await readBody(event)
6
+ const { project_id, code, name, is_default, fallback_code } = body
7
+
8
+ if (!project_id) throw createError({ statusCode: 400, message: 'project_id is required' })
9
+ if (!code || !name) throw createError({ statusCode: 400, message: 'code and name are required' })
10
+
11
+ const db = getDb()
12
+
13
+ if (is_default) {
14
+ await db('languages').where({ project_id: Number(project_id) }).update({ is_default: false })
15
+ }
16
+
17
+ const existing = await db('languages').where({ project_id: Number(project_id), code }).first()
18
+ if (existing) {
19
+ await db('languages').where({ id: existing.id }).update({
20
+ name,
21
+ is_default: is_default || false,
22
+ fallback_code: fallback_code ?? existing.fallback_code,
23
+ })
24
+ return db('languages').where({ id: existing.id }).first()
25
+ }
26
+
27
+ const [id] = await db('languages').insert({
28
+ project_id: Number(project_id),
29
+ code,
30
+ name,
31
+ is_default: is_default || false,
32
+ fallback_code: fallback_code || null,
33
+ })
34
+
35
+ // If this is the system project and the language isn't English, auto-translate UI strings
36
+ const project = await db('projects').where({ id: Number(project_id) }).first()
37
+ if (project?.is_system && code !== 'en') {
38
+ setImmediate(() => autoTranslateUtil(db, code))
39
+ }
40
+
41
+ return db('languages').where({ id }).first()
42
+ })
@@ -0,0 +1,56 @@
1
+ import { getDb } from '../db/index'
2
+ import { autoTranslateUtil } from '../utils/auto-translate.util'
3
+
4
+ // Saves onboarding configuration: UI languages for Dashboard UI project
5
+ export default defineEventHandler(async (event) => {
6
+ const body = await readBody(event)
7
+ const { languages, defaultLanguage } = body // languages: Array<{ code, name }>
8
+
9
+ const db = getDb()
10
+ const systemProject = await db('projects').where({ is_system: true }).first()
11
+
12
+ const langsToTranslate: string[] = []
13
+
14
+ if (systemProject && languages?.length) {
15
+ const existingLangs = await db('languages').where({ project_id: systemProject.id }).select('code')
16
+ const existingCodes = existingLangs.map((l: any) => l.code)
17
+
18
+ for (const lang of languages) {
19
+ if (!existingCodes.includes(lang.code)) {
20
+ await db('languages').insert({
21
+ project_id: systemProject.id,
22
+ code: lang.code,
23
+ name: lang.name,
24
+ is_default: lang.code === defaultLanguage,
25
+ })
26
+ // Queue translation for non-English new languages
27
+ if (lang.code !== 'en') {
28
+ langsToTranslate.push(lang.code)
29
+ }
30
+ } else {
31
+ await db('languages')
32
+ .where({ project_id: systemProject.id, code: lang.code })
33
+ .update({ is_default: lang.code === defaultLanguage })
34
+ }
35
+ }
36
+ }
37
+
38
+ // Mark onboarding as completed (upsert to handle missing row)
39
+ const existing = await db('settings').where({ key: 'onboarding_completed' }).first()
40
+ if (existing) {
41
+ await db('settings').where({ key: 'onboarding_completed' }).update({ value: 'true' })
42
+ } else {
43
+ await db('settings').insert({ key: 'onboarding_completed', value: 'true' })
44
+ }
45
+
46
+ // Fire-and-forget: translate UI strings for each new non-EN language
47
+ if (langsToTranslate.length) {
48
+ setImmediate(async () => {
49
+ for (const code of langsToTranslate) {
50
+ await autoTranslateUtil(db, code)
51
+ }
52
+ })
53
+ }
54
+
55
+ return { success: true }
56
+ })
@@ -0,0 +1,81 @@
1
+ import { getDb } from '../db/index'
2
+ import type { UserProfile } from '../interfaces/profile.interface'
3
+
4
+ export default defineEventHandler(async (event): Promise<UserProfile> => {
5
+ const user = event.context.user
6
+ const db = getDb()
7
+
8
+ // ── Roles with project info ───────────────────────────────────────────────
9
+ const roles = await db('user_project_roles as upr')
10
+ .leftJoin('projects as p', 'p.id', 'upr.project_id')
11
+ .where('upr.user_id', user.id)
12
+ .select('upr.role', 'upr.project_id', 'p.name as project_name', 'p.color as project_color')
13
+
14
+ // ── Languages accessible to the user ─────────────────────────────────────
15
+ const projectIds = roles.filter((r: any) => r.project_id).map((r: any) => r.project_id)
16
+
17
+ let languages: any[]
18
+ if (user.is_super_admin) {
19
+ languages = await db('languages as l')
20
+ .join('projects as p', 'p.id', 'l.project_id')
21
+ .where('p.is_system', false)
22
+ .select('l.*', 'p.name as project_name', 'p.color as project_color')
23
+ } else if (projectIds.length) {
24
+ languages = await db('languages as l')
25
+ .join('projects as p', 'p.id', 'l.project_id')
26
+ .whereIn('l.project_id', projectIds)
27
+ .where('p.is_system', false)
28
+ .select('l.*', 'p.name as project_name', 'p.color as project_color')
29
+ } else {
30
+ languages = []
31
+ }
32
+
33
+ // ── Stats ─────────────────────────────────────────────────────────────────
34
+ const weekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString()
35
+ const monthAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString()
36
+
37
+ const [countTotal, countWeek, countMonth] = await Promise.all([
38
+ db('translation_history').where('changed_by', user.name).count('id as n').first(),
39
+ db('translation_history').where('changed_by', user.name).where('changed_at', '>=', weekAgo).count('id as n').first(),
40
+ db('translation_history').where('changed_by', user.name).where('changed_at', '>=', monthAgo).count('id as n').first(),
41
+ ])
42
+
43
+ // ── Recent translations ───────────────────────────────────────────────────
44
+ const recentTranslations = await db('translation_history as th')
45
+ .join('translations as t', 't.id', 'th.translation_id')
46
+ .join('translation_keys as tk', 'tk.id', 't.key_id')
47
+ .join('projects as p', 'p.id', 'tk.project_id')
48
+ .where('th.changed_by', user.name)
49
+ .orderBy('th.changed_at', 'desc')
50
+ .limit(20)
51
+ .select(
52
+ 'th.id',
53
+ 'th.new_value',
54
+ 'th.old_value',
55
+ 'th.changed_at',
56
+ 'tk.key',
57
+ 'tk.id as key_id',
58
+ 't.language_code',
59
+ 'p.name as project_name',
60
+ 'p.color as project_color',
61
+ )
62
+
63
+ return {
64
+ user: {
65
+ id: user.id,
66
+ name: user.name,
67
+ email: user.email,
68
+ is_super_admin: !!user.is_super_admin,
69
+ last_login_at: user.last_login_at ?? null,
70
+ created_at: user.created_at,
71
+ },
72
+ roles,
73
+ stats: {
74
+ total: Number(countTotal?.n ?? 0),
75
+ thisWeek: Number(countWeek?.n ?? 0),
76
+ thisMonth: Number(countMonth?.n ?? 0),
77
+ },
78
+ languages,
79
+ recentTranslations,
80
+ }
81
+ })
@@ -0,0 +1,73 @@
1
+ import { getDb } from '../db/index'
2
+
3
+ export default defineEventHandler(async (event) => {
4
+ const query = getQuery(event)
5
+ const projectId = query.project_id ? Number(query.project_id) : null
6
+
7
+ if (!projectId) throw createError({ statusCode: 400, message: 'project_id is required' })
8
+
9
+ const db = getDb()
10
+ const project = await db('projects').where({ id: projectId }).first()
11
+ if (!project) throw createError({ statusCode: 404, message: 'Project not found' })
12
+ if (project.is_system) throw createError({ statusCode: 403, message: 'System project cannot be exported' })
13
+
14
+ const languages = await db('languages')
15
+ .where({ project_id: projectId })
16
+ .select('code', 'name', 'is_default', 'fallback_code')
17
+
18
+ const keyRows = await db('translation_keys')
19
+ .where({ project_id: projectId })
20
+ .select('id', 'key', 'description')
21
+
22
+ const translationRows = await db('translations as t')
23
+ .join('translation_keys as k', 't.key_id', 'k.id')
24
+ .where('k.project_id', projectId)
25
+ .select('k.key', 't.language_code', 't.value', 't.status')
26
+
27
+ // Build keys map: key → { description, translations: { lang: { value, status } } }
28
+ const keysMap: Record<string, { description: string | null; translations: Record<string, { value: string; status: string }> }> = {}
29
+
30
+ for (const row of keyRows) {
31
+ keysMap[row.key] = { description: row.description ?? null, translations: {} }
32
+ }
33
+
34
+ for (const row of translationRows) {
35
+ if (!keysMap[row.key]) continue
36
+ if (row.value !== null && row.value !== '') {
37
+ keysMap[row.key].translations[row.language_code] = {
38
+ value: row.value,
39
+ status: row.status ?? 'draft',
40
+ }
41
+ }
42
+ }
43
+
44
+ const snapshot = {
45
+ version: 1,
46
+ exportedAt: new Date().toISOString(),
47
+ project: {
48
+ name: project.name,
49
+ locales_path: project.locales_path,
50
+ key_separator: project.key_separator,
51
+ color: project.color ?? null,
52
+ description: project.description ?? null,
53
+ source_url: project.source_url ?? null,
54
+ },
55
+ languages: languages.map(l => ({
56
+ code: l.code,
57
+ name: l.name,
58
+ is_default: !!l.is_default,
59
+ fallback_code: l.fallback_code ?? null,
60
+ })),
61
+ keys: Object.entries(keysMap).map(([key, data]) => ({
62
+ key,
63
+ description: data.description,
64
+ translations: data.translations,
65
+ })),
66
+ }
67
+
68
+ const filename = `${project.name.replace(/[^a-z0-9]/gi, '_')}_snapshot.json`
69
+ setHeader(event, 'Content-Type', 'application/json')
70
+ setHeader(event, 'Content-Disposition', `attachment; filename="${filename}"`)
71
+
72
+ return snapshot
73
+ })
@@ -0,0 +1,160 @@
1
+ import { getDb } from '../db/index'
2
+
3
+ interface SnapshotKey {
4
+ key: string
5
+ description?: string | null
6
+ translations: Record<string, { value: string; status?: string } | string>
7
+ }
8
+
9
+ interface SnapshotLanguage {
10
+ code: string
11
+ name: string
12
+ is_default?: boolean
13
+ fallback_code?: string | null
14
+ }
15
+
16
+ interface ProjectSnapshot {
17
+ version: number
18
+ project: {
19
+ name: string
20
+ locales_path: string
21
+ key_separator: string
22
+ color?: string | null
23
+ description?: string | null
24
+ source_url?: string | null
25
+ }
26
+ languages: SnapshotLanguage[]
27
+ keys: SnapshotKey[]
28
+ }
29
+
30
+ export default defineEventHandler(async (event) => {
31
+ const body = await readBody(event)
32
+ const { snapshot, project_id, mode = 'merge' } = body as {
33
+ snapshot: ProjectSnapshot
34
+ project_id?: number
35
+ mode?: 'merge' | 'replace'
36
+ }
37
+
38
+ if (!snapshot?.version || !snapshot?.project || !Array.isArray(snapshot.keys)) {
39
+ throw createError({ statusCode: 400, message: 'Invalid snapshot format' })
40
+ }
41
+
42
+ const db = getDb()
43
+ let projectId = project_id ? Number(project_id) : null
44
+ let stats = { keys_added: 0, keys_updated: 0, translations_added: 0, translations_updated: 0, languages_added: 0 }
45
+
46
+ // ── Resolve or create project ──────────────────────────────────────────
47
+ if (projectId) {
48
+ const existing = await db('projects').where({ id: projectId }).first()
49
+ if (!existing) throw createError({ statusCode: 404, message: 'Target project not found' })
50
+ // Update project metadata
51
+ await db('projects').where({ id: projectId }).update({
52
+ name: snapshot.project.name,
53
+ locales_path: snapshot.project.locales_path,
54
+ key_separator: snapshot.project.key_separator,
55
+ color: snapshot.project.color ?? existing.color,
56
+ description: snapshot.project.description ?? existing.description,
57
+ source_url: snapshot.project.source_url ?? existing.source_url,
58
+ })
59
+ } else {
60
+ // Create new project
61
+ const [id] = await db('projects').insert({
62
+ name: snapshot.project.name,
63
+ locales_path: snapshot.project.locales_path,
64
+ key_separator: snapshot.project.key_separator,
65
+ color: snapshot.project.color ?? '#6366f1',
66
+ description: snapshot.project.description ?? null,
67
+ source_url: snapshot.project.source_url ?? null,
68
+ is_system: false,
69
+ })
70
+ projectId = id
71
+ }
72
+
73
+ const separator = snapshot.project.key_separator || '.'
74
+
75
+ // ── Upsert languages ──────────────────────────────────────────────────
76
+ for (const lang of snapshot.languages) {
77
+ const existing = await db('languages').where({ project_id: projectId, code: lang.code }).first()
78
+ if (existing) {
79
+ await db('languages').where({ id: existing.id }).update({
80
+ name: lang.name,
81
+ is_default: lang.is_default ? 1 : 0,
82
+ fallback_code: lang.fallback_code ?? null,
83
+ })
84
+ } else {
85
+ await db('languages').insert({
86
+ project_id: projectId,
87
+ code: lang.code,
88
+ name: lang.name,
89
+ is_default: lang.is_default ? 1 : 0,
90
+ fallback_code: lang.fallback_code ?? null,
91
+ })
92
+ stats.languages_added++
93
+ }
94
+ }
95
+
96
+ // ── Replace mode: clear existing keys ────────────────────────────────
97
+ if (mode === 'replace') {
98
+ const keyIds = await db('translation_keys').where({ project_id: projectId }).pluck('id')
99
+ if (keyIds.length) {
100
+ await db('translations').whereIn('key_id', keyIds).delete()
101
+ await db('key_usages').whereIn('key_id', keyIds).delete()
102
+ await db('translation_keys').where({ project_id: projectId }).delete()
103
+ }
104
+ }
105
+
106
+ // ── Upsert keys + translations ────────────────────────────────────────
107
+ for (const entry of snapshot.keys) {
108
+ if (!entry.key) continue
109
+
110
+ let keyRecord = await db('translation_keys').where({ project_id: projectId, key: entry.key }).first()
111
+ if (!keyRecord) {
112
+ const [id] = await db('translation_keys').insert({
113
+ project_id: projectId,
114
+ key: entry.key,
115
+ description: entry.description ?? null,
116
+ })
117
+ keyRecord = { id }
118
+ stats.keys_added++
119
+ } else if (entry.description !== undefined) {
120
+ await db('translation_keys').where({ id: keyRecord.id }).update({ description: entry.description })
121
+ stats.keys_updated++
122
+ }
123
+
124
+ for (const [langCode, translationData] of Object.entries(entry.translations)) {
125
+ const value = typeof translationData === 'string' ? translationData : translationData.value
126
+ const status = typeof translationData === 'object' ? (translationData.status ?? 'draft') : 'draft'
127
+ if (!value) continue
128
+
129
+ const existing = await db('translations').where({ key_id: keyRecord.id, language_code: langCode }).first()
130
+ if (existing) {
131
+ if (existing.value !== value) {
132
+ await db('translation_history').insert({
133
+ translation_id: existing.id,
134
+ old_value: existing.value,
135
+ new_value: value,
136
+ changed_by: 'snapshot-import',
137
+ })
138
+ await db('translations').where({ id: existing.id }).update({ value, status, updated_at: db.fn.now() })
139
+ stats.translations_updated++
140
+ }
141
+ } else {
142
+ const [id] = await db('translations').insert({
143
+ key_id: keyRecord.id,
144
+ language_code: langCode,
145
+ value,
146
+ status,
147
+ })
148
+ await db('translation_history').insert({
149
+ translation_id: id,
150
+ old_value: null,
151
+ new_value: value,
152
+ changed_by: 'snapshot-import',
153
+ })
154
+ stats.translations_added++
155
+ }
156
+ }
157
+ }
158
+
159
+ return { success: true, project_id: projectId, stats }
160
+ })
@@ -0,0 +1,13 @@
1
+ import { getDb } from '../../db/index'
2
+
3
+ export default defineEventHandler(async (event) => {
4
+ const id = Number(getRouterParam(event, 'id'))
5
+ const db = getDb()
6
+
7
+ const project = await db('projects').where({ id }).first()
8
+ if (!project) throw createError({ statusCode: 404, message: 'Project not found' })
9
+ if (project.is_system) throw createError({ statusCode: 403, message: 'Le projet système ne peut pas être supprimé' })
10
+
11
+ await db('projects').where({ id }).delete()
12
+ return { success: true }
13
+ })
@@ -0,0 +1,40 @@
1
+ import { getDb } from '../../db/index'
2
+ import { existsSync } from 'fs'
3
+ import { resolve } from 'path'
4
+
5
+ export default defineEventHandler(async (event) => {
6
+ const id = Number(getRouterParam(event, 'id'))
7
+ const body = await readBody(event)
8
+ const { name, root_path, source_url, locales_path, key_separator, color, description, enable_number_formats, enable_datetime_formats, enable_modifiers } = body
9
+
10
+ const db = getDb()
11
+ const project = await db('projects').where({ id }).first()
12
+ if (!project) throw createError({ statusCode: 404, message: 'Project not found' })
13
+ if (project.is_system) throw createError({ statusCode: 403, message: 'La configuration du projet système ne peut pas être modifiée' })
14
+
15
+ const updates: Record<string, any> = {}
16
+ if (name !== undefined) updates.name = name.trim()
17
+ if (locales_path !== undefined) updates.locales_path = locales_path
18
+ if (key_separator !== undefined) updates.key_separator = key_separator
19
+ if (color !== undefined) updates.color = color
20
+ if (description !== undefined) updates.description = description
21
+ if (source_url !== undefined) updates.source_url = source_url?.trim() || null
22
+ if (enable_number_formats !== undefined) updates.enable_number_formats = enable_number_formats
23
+ if (enable_datetime_formats !== undefined) updates.enable_datetime_formats = enable_datetime_formats
24
+ if (enable_modifiers !== undefined) updates.enable_modifiers = enable_modifiers
25
+
26
+ if (root_path !== undefined) {
27
+ if (root_path.trim() === '') {
28
+ updates.root_path = ''
29
+ } else {
30
+ const absolutePath = resolve(root_path)
31
+ if (!existsSync(absolutePath)) {
32
+ throw createError({ statusCode: 400, message: `Path does not exist: ${absolutePath}` })
33
+ }
34
+ updates.root_path = absolutePath
35
+ }
36
+ }
37
+
38
+ await db('projects').where({ id }).update(updates)
39
+ return db('projects').where({ id }).first()
40
+ })
@@ -0,0 +1,19 @@
1
+ import { getDb } from '../../db/index'
2
+
3
+ export default defineEventHandler(async () => {
4
+ const db = getDb()
5
+ const projects = await db('projects').select('*').orderBy('created_at', 'asc')
6
+
7
+ // For each project, add language count + key count
8
+ const withStats = await Promise.all(projects.map(async (p: any) => {
9
+ const keyCount = await db('translation_keys').where({ project_id: p.id }).count('* as count').first()
10
+ const langCount = await db('languages').where({ project_id: p.id }).count('* as count').first()
11
+ return {
12
+ ...p,
13
+ key_count: Number((keyCount as any)?.count || 0),
14
+ language_count: Number((langCount as any)?.count || 0),
15
+ }
16
+ }))
17
+
18
+ return withStats
19
+ })
@@ -0,0 +1,34 @@
1
+ import { getDb } from '../../db/index'
2
+ import { existsSync } from 'fs'
3
+ import { resolve } from 'path'
4
+
5
+ export default defineEventHandler(async (event) => {
6
+ const body = await readBody(event)
7
+ const { name, root_path, source_url, locales_path, key_separator, color, description } = body
8
+
9
+ if (!name?.trim()) throw createError({ statusCode: 400, message: 'name is required' })
10
+ if (!root_path?.trim() && !source_url?.trim()) {
11
+ throw createError({ statusCode: 400, message: 'root_path or source_url is required' })
12
+ }
13
+
14
+ let absolutePath = ''
15
+ if (root_path?.trim()) {
16
+ absolutePath = resolve(root_path)
17
+ if (!existsSync(absolutePath)) {
18
+ throw createError({ statusCode: 400, message: `Path does not exist: ${absolutePath}` })
19
+ }
20
+ }
21
+
22
+ const db = getDb()
23
+ const [id] = await db('projects').insert({
24
+ name: name.trim(),
25
+ root_path: absolutePath,
26
+ source_url: source_url?.trim() || null,
27
+ locales_path: locales_path || 'src/locales',
28
+ key_separator: key_separator || '.',
29
+ color: color || 'primary',
30
+ description: description || null,
31
+ })
32
+
33
+ return db('projects').where({ id }).first()
34
+ })