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.
- package/assets/locales/en.json +16 -1
- package/components/ScanModal.vue +3 -3
- package/package.json +1 -1
- package/pages/projects/[id]/settings.vue +2 -2
- package/server/api/projects/[id].put.ts +4 -1
- package/server/api/scan.post.ts +124 -2
- package/server/api/sync.post.ts +2 -1
- package/server/db/index.ts +11 -0
- package/server/routes/locale/[lang].get.ts +3 -2
package/assets/locales/en.json
CHANGED
|
@@ -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
|
}
|
package/components/ScanModal.vue
CHANGED
|
@@ -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', '
|
|
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
|
@@ -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', '
|
|
40
|
-
<
|
|
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 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() === '') {
|
package/server/api/scan.post.ts
CHANGED
|
@@ -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
|
|
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) {
|
package/server/api/sync.post.ts
CHANGED
|
@@ -109,7 +109,8 @@ export default defineEventHandler(async (event) => {
|
|
|
109
109
|
}
|
|
110
110
|
|
|
111
111
|
for (const lang of languages) {
|
|
112
|
-
const
|
|
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)
|
package/server/db/index.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|