i18n-dashboard 0.1.0 → 0.3.7

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.
@@ -376,5 +376,20 @@
376
376
  "scan.unused": "unused",
377
377
  "scan.files_scanned": "files scanned",
378
378
  "scan.errors": "errors",
379
- "scan.run": "Scan"
379
+ "scan.run": "Scan",
380
+ "scan.mode_git": "Git",
381
+ "scan.git_hint": "Enter the Git repository URL. The scanner will clone it and detect all translation key usages in source files.",
382
+ "scan.git_url_label": "Repository URL",
383
+ "scan.git_url_hint": "Example: https://github.com/user/repo.git",
384
+ "scan.git_branch_label": "Branch",
385
+ "scan.git_branch_hint": "Leave empty to use main",
386
+ "scan.git_token_label": "Access token",
387
+ "scan.git_token_hint": "Required for private repositories",
388
+ "scan.git_token_placeholder": "ghp_xxxxxxxxxxxx",
389
+ "settings.git_url": "Git repository URL",
390
+ "settings.git_url_hint": "Used for Git scan mode",
391
+ "settings.git_branch": "Git branch",
392
+ "settings.git_branch_hint": "Default: main",
393
+ "settings.git_token": "Git access token",
394
+ "settings.git_token_hint": "For private repositories"
380
395
  }
@@ -34,7 +34,7 @@
34
34
  <p class="text-sm text-gray-500 dark:text-gray-400">
35
35
  {{ t('scan.url_hint', 'Enter the base URL of your app. The scanner will fetch each configured locale file (en.json, fr.json…) and import all keys it finds.') }}
36
36
  </p>
37
- <UFormField :label="t('scan.url_label', 'Base URL')" :hint="t('scan.url_hint2', 'Example: https://my-app.com')">
37
+ <UFormField :label="t('scan.url_label', 'Base URL')" :hint="t('scan.url_hint2', 'First configured URL is used by default')">
38
38
  <UInput v-model="remoteUrl" class="w-full" placeholder="https://my-app.com" />
39
39
  </UFormField>
40
40
  <div v-if="!project?.languages?.length" class="flex items-start gap-2 bg-amber-50 dark:bg-amber-900/20 rounded-lg px-3 py-2 text-xs text-amber-700 dark:text-amber-300">
@@ -109,7 +109,7 @@ const open = defineModel<boolean>('open', { default: false })
109
109
 
110
110
  const mode = ref<'local' | 'url'>('local')
111
111
  const localPath = ref(props.project?.root_path ?? '')
112
- const remoteUrl = ref(props.project?.source_url ?? '')
112
+ const remoteUrl = ref(props.project?.source_url?.split(/[\n,]+/).map(u => u.trim()).filter(Boolean)[0] ?? '')
113
113
  const loading = ref(false)
114
114
  const result = ref<any>(null)
115
115
  const error = ref('')
@@ -124,7 +124,7 @@ watch(open, (val) => {
124
124
  result.value = null
125
125
  error.value = ''
126
126
  localPath.value = props.project?.root_path ?? ''
127
- remoteUrl.value = props.project?.source_url ?? ''
127
+ remoteUrl.value = props.project?.source_url?.split(/[\n,]+/).map(u => u.trim()).filter(Boolean)[0] ?? ''
128
128
  mode.value = props.project?.root_path ? 'local' : props.project?.source_url ? 'url' : 'local'
129
129
  }
130
130
  })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "i18n-dashboard",
3
- "version": "0.1.0",
3
+ "version": "0.3.7",
4
4
  "description": "A web dashboard to manage vue-i18n translation keys with database persistence",
5
5
  "type": "module",
6
6
  "bin": {
@@ -36,8 +36,8 @@
36
36
  <UFormField :label="t('settings.root_path', 'Root path')" :hint="t('settings.root_path_hint', 'Absolute path to the project root')">
37
37
  <PathPicker v-model="form.root_path" class="w-full" />
38
38
  </UFormField>
39
- <UFormField :label="t('settings.source_url', 'Source URL')" :hint="t('settings.source_url_hint', 'App URL (for CORS auto-detection)')">
40
- <UInput v-model="form.source_url" class="w-full" placeholder="https://my-app.com"/>
39
+ <UFormField class="col-span-2" :label="t('settings.source_url', 'App URLs')" :hint="t('settings.source_url_hint', 'One URL per line — all allowed for CORS, first used for scan/sync')">
40
+ <UTextarea v-model="form.source_url" class="w-full" :rows="3" placeholder="https://my-app.com&#10;https://staging.my-app.com"/>
41
41
  </UFormField>
42
42
  <UFormField :label="t('settings.locales_folder', 'Locales folder')" :hint="t('settings.locales_folder_hint', 'Relative to root')">
43
43
  <UInput v-model="form.locales_path" class="w-full" placeholder="src/locales"/>
@@ -5,7 +5,7 @@ import { resolve } from 'path'
5
5
  export default defineEventHandler(async (event) => {
6
6
  const id = Number(getRouterParam(event, 'id'))
7
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
8
+ const { name, root_path, source_url, locales_path, key_separator, color, description, enable_number_formats, enable_datetime_formats, enable_modifiers, git_url, git_token, git_branch } = body
9
9
 
10
10
  const db = getDb()
11
11
  const project = await db('projects').where({ id }).first()
@@ -22,6 +22,9 @@ export default defineEventHandler(async (event) => {
22
22
  if (enable_number_formats !== undefined) updates.enable_number_formats = enable_number_formats
23
23
  if (enable_datetime_formats !== undefined) updates.enable_datetime_formats = enable_datetime_formats
24
24
  if (enable_modifiers !== undefined) updates.enable_modifiers = enable_modifiers
25
+ if (git_url !== undefined) updates.git_url = git_url?.trim() || null
26
+ if (git_token !== undefined) updates.git_token = git_token?.trim() || null
27
+ if (git_branch !== undefined) updates.git_branch = git_branch?.trim() || null
25
28
 
26
29
  if (root_path !== undefined) {
27
30
  if (root_path.trim() === '') {
@@ -1,10 +1,13 @@
1
1
  import { resolve } from 'path'
2
+ import { mkdtempSync, rmSync } from 'fs'
3
+ import { tmpdir } from 'os'
4
+ import { execSync } from 'child_process'
2
5
  import { getDb } from '../db/index'
3
6
  import { scanProject, detectLanguages } from '../utils/scanner.uti'
4
7
 
5
8
  export default defineEventHandler(async (event) => {
6
9
  const body = await readBody(event)
7
- const { project_id, mode = 'local', root_path: bodyRootPath, url: bodyUrl } = body
10
+ const { project_id, mode = 'local', root_path: bodyRootPath, url: bodyUrl, git_url: bodyGitUrl, git_token: bodyGitToken, git_branch: bodyGitBranch } = body
8
11
 
9
12
  if (!project_id) throw createError({ statusCode: 400, message: 'project_id is required' })
10
13
 
@@ -15,7 +18,8 @@ export default defineEventHandler(async (event) => {
15
18
 
16
19
  // ── URL mode: fetch locale files and import keys ───────────────────────
17
20
  if (mode === 'url') {
18
- const baseUrl = (bodyUrl || project.source_url || '').replace(/\/$/, '')
21
+ const rawUrl = bodyUrl || project.source_url?.split(/[\n,]+/).map((u: string) => u.trim()).filter(Boolean)[0] || ''
22
+ const baseUrl = rawUrl.replace(/\/$/, '')
19
23
  if (!baseUrl) throw createError({ statusCode: 400, message: 'No URL provided' })
20
24
 
21
25
  const languages = await db('languages').where({ project_id: Number(project_id) }).select('code')
@@ -59,6 +63,124 @@ export default defineEventHandler(async (event) => {
59
63
  return { keysImported: keysFound, keysAdded, total: Number((totalKeys as any)?.count || 0) }
60
64
  }
61
65
 
66
+ // ── Git mode: clone repo and scan source files ────────────────────────
67
+ if (mode === 'git') {
68
+ const gitUrl = bodyGitUrl || project.git_url
69
+ if (!gitUrl) throw createError({ statusCode: 400, message: 'No git URL provided' })
70
+
71
+ const token = bodyGitToken || project.git_token || ''
72
+ const branch = bodyGitBranch || project.git_branch || 'main'
73
+
74
+ // Inject token into URL for private repos
75
+ let authUrl = gitUrl
76
+ if (token) {
77
+ try {
78
+ const parsed = new URL(gitUrl)
79
+ parsed.username = 'oauth2'
80
+ parsed.password = token
81
+ authUrl = parsed.toString()
82
+ } catch {
83
+ authUrl = gitUrl.replace('https://', `https://oauth2:${token}@`)
84
+ }
85
+ }
86
+
87
+ const tmpDir = mkdtempSync(resolve(tmpdir(), 'i18n-scan-'))
88
+ try {
89
+ execSync(
90
+ `git clone --depth 1 --branch ${branch} --single-branch ${authUrl} ${tmpDir}`,
91
+ { stdio: 'pipe', timeout: 60000 },
92
+ )
93
+ } catch (e: any) {
94
+ rmSync(tmpDir, { recursive: true, force: true })
95
+ const msg = e?.stderr?.toString() || e?.message || 'Git clone failed'
96
+ throw createError({ statusCode: 400, message: msg.replace(/https?:\/\/[^@]+@/, 'https://***@') })
97
+ }
98
+
99
+ try {
100
+ const settings = await db('settings').select('*')
101
+ const settingsMap: Record<string, string> = {}
102
+ for (const s of settings) settingsMap[s.key] = s.value
103
+
104
+ const excludeDirs = (settingsMap['scan_exclude'] || 'node_modules,dist,.nuxt,.output,.git')
105
+ .split(',').map((s) => s.trim()).filter(Boolean)
106
+
107
+ const detectedLangs = detectLanguages({ projectRoot: tmpDir, localesPath: project.locales_path })
108
+ let langsAdded = 0
109
+ for (const lang of detectedLangs) {
110
+ const existing = await db('languages').where({ project_id: Number(project_id), code: lang.code }).first()
111
+ if (!existing) {
112
+ await db('languages').insert({ project_id: Number(project_id), code: lang.code, name: lang.name, is_default: false })
113
+ langsAdded++
114
+ }
115
+ }
116
+
117
+ const { usages, scannedFiles, errors } = scanProject({
118
+ projectRoot: tmpDir,
119
+ excludeDirs,
120
+ extensions: ['.vue', '.ts', '.js', '.mts', '.mjs'],
121
+ })
122
+
123
+ const keyMap = new Map<string, typeof usages>()
124
+ for (const usage of usages) {
125
+ const list = keyMap.get(usage.key) || []
126
+ list.push(usage)
127
+ keyMap.set(usage.key, list)
128
+ }
129
+
130
+ const now = db.fn.now()
131
+ let keysAdded = 0
132
+ let keysFound = 0
133
+
134
+ const existingKeys = await db('translation_keys').where({ project_id: Number(project_id) }).select('id', 'key')
135
+ const existingKeyMap = new Map<string, number>()
136
+ for (const k of existingKeys) existingKeyMap.set(k.key, k.id)
137
+
138
+ if (existingKeys.length > 0) {
139
+ await db('key_usages').whereIn('key_id', existingKeys.map(k => k.id)).delete()
140
+ }
141
+
142
+ for (const [key, keyUsages] of keyMap.entries()) {
143
+ let keyId = existingKeyMap.get(key)
144
+ if (!keyId) {
145
+ const [id] = await db('translation_keys').insert({ project_id: Number(project_id), key, is_unused: false, last_scanned_at: now })
146
+ keyId = id
147
+ keysAdded++
148
+ } else {
149
+ await db('translation_keys').where({ id: keyId }).update({ is_unused: false, last_scanned_at: now })
150
+ }
151
+ keysFound++
152
+ await db('key_usages').insert(keyUsages.map(u => ({
153
+ key_id: keyId,
154
+ file_path: u.filePath,
155
+ line_number: u.lineNumber,
156
+ detected_function: u.detectedFunction,
157
+ scanned_at: now,
158
+ })))
159
+ }
160
+
161
+ const foundKeys = new Set(keyMap.keys())
162
+ const unusedIds = existingKeys.filter(k => !foundKeys.has(k.key)).map(k => k.id)
163
+ if (unusedIds.length > 0) {
164
+ await db('translation_keys').whereIn('id', unusedIds).update({ is_unused: true, last_scanned_at: now })
165
+ }
166
+
167
+ const totalKeys = await db('translation_keys').where({ project_id: Number(project_id) }).count('* as count').first()
168
+
169
+ return {
170
+ keysFound,
171
+ keysAdded,
172
+ unusedKeys: unusedIds.length,
173
+ scannedFiles: scannedFiles.length,
174
+ total: Number((totalKeys as any)?.count || 0),
175
+ langsDetected: detectedLangs.length,
176
+ langsAdded,
177
+ errors: errors.slice(0, 10),
178
+ }
179
+ } finally {
180
+ rmSync(tmpDir, { recursive: true, force: true })
181
+ }
182
+ }
183
+
62
184
  // ── Local mode: scan source files ─────────────────────────────────────
63
185
  const rootPath = bodyRootPath || project.root_path
64
186
  if (!rootPath) {
@@ -109,7 +109,8 @@ export default defineEventHandler(async (event) => {
109
109
  }
110
110
 
111
111
  for (const lang of languages) {
112
- const remoteData = await fetchRemoteLocale(project.source_url, lang.code)
112
+ const primaryUrl = project.source_url?.split(/[\n,]+/).map((u: string) => u.trim()).filter(Boolean)[0] || project.source_url
113
+ const remoteData = await fetchRemoteLocale(primaryUrl, lang.code)
113
114
  if (!remoteData) continue
114
115
 
115
116
  const flattened = flattenObject(remoteData, separator)
@@ -669,4 +669,15 @@ export async function initDb(): Promise<void> {
669
669
  await addColumnIfMissing(db, 'projects', 'enable_modifiers', (t) =>
670
670
  t.boolean('enable_modifiers').defaultTo(false),
671
671
  )
672
+
673
+ // ── migration: git columns on projects ───────────────────────────────────
674
+ await addColumnIfMissing(db, 'projects', 'git_url', (t) =>
675
+ t.text('git_url').nullable(),
676
+ )
677
+ await addColumnIfMissing(db, 'projects', 'git_token', (t) =>
678
+ t.text('git_token').nullable(),
679
+ )
680
+ await addColumnIfMissing(db, 'projects', 'git_branch', (t) =>
681
+ t.text('git_branch').nullable(),
682
+ )
672
683
  }
@@ -111,7 +111,7 @@ export default defineEventHandler(async (event) => {
111
111
  if (p) projectId = p.id
112
112
  }
113
113
 
114
- // 2. Match by request Origin / Referer against project source_url
114
+ // 2. Match by request Origin / Referer against project source_url (supports multiple URLs, one per line)
115
115
  if (!projectId) {
116
116
  const requestOrigin = getHeader(event, 'origin') || getHeader(event, 'referer') || ''
117
117
  if (requestOrigin) {
@@ -122,7 +122,8 @@ export default defineEventHandler(async (event) => {
122
122
  .select('id', 'source_url')
123
123
 
124
124
  for (const p of projectsWithUrl) {
125
- if (normalizeOrigin(p.source_url) === normalizedRequest) {
125
+ const urls = p.source_url.split(/[\n,]+/).map((u: string) => u.trim()).filter(Boolean)
126
+ if (urls.some((u: string) => normalizeOrigin(u) === normalizedRequest)) {
126
127
  projectId = p.id
127
128
  break
128
129
  }