[READ-ONLY] One Calendar is a privacy-first calendar web app built with Next.js. It has modern security features, including e2ee, password-protected sharing, and self-destructing share links 📅
calendar.xyehr.cn
nextjs
5.8 kB
198 lines
1import fs from 'node:fs/promises'
2import path from 'node:path'
3import { fileURLToPath } from 'node:url'
4import readline from 'node:readline'
5
6const __filename = fileURLToPath(import.meta.url)
7const __dirname = path.dirname(__filename)
8const projectRoot = path.resolve(__dirname, '..')
9const localesDir = path.join(projectRoot, 'locales')
10const sourceLocale = 'en.json'
11
12const rl = readline.createInterface({
13 input: process.stdin,
14 output: process.stdout,
15})
16
17const question = (query) => new Promise((resolve) => rl.question(query, resolve))
18
19/**
20 * Parses user input like "1, 3-5, 8" into an array of 0-based indices.
21 */
22function parseIndices(input, max) {
23 if (input.toLowerCase() === 'all') {
24 return Array.from({ length: max }, (_, i) => i)
25 }
26
27 const result = new Set()
28 const parts = input.split(/[\s,]+/)
29
30 for (const part of parts) {
31 if (part.includes('-')) {
32 const split = part.split('-').map((n) => parseInt(n.trim(), 10))
33 if (split.length === 2) {
34 const [start, end] = split
35 if (!isNaN(start) && !isNaN(end)) {
36 for (let i = Math.min(start, end); i <= Math.max(start, end); i++) {
37 if (i >= 1 && i <= max) result.add(i - 1)
38 }
39 }
40 }
41 } else {
42 const n = parseInt(part.trim(), 10)
43 if (!isNaN(n) && n >= 1 && n <= max) {
44 result.add(n - 1)
45 }
46 }
47 }
48
49 return Array.from(result).sort((a, b) => a - b)
50}
51
52async function getFiles(dir) {
53 const dirents = await fs.readdir(dir, { withFileTypes: true })
54 const files = await Promise.all(
55 dirents.map((dirent) => {
56 const res = path.resolve(dir, dirent.name)
57 return dirent.isDirectory() ? getFiles(res) : res
58 }),
59 )
60 return files.flat()
61}
62
63/**
64 * Escapes special characters for use in a regular expression.
65 */
66function escapeRegExp(string) {
67 return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
68}
69
70async function run() {
71 console.log('🔍 Scanning for unused i18n keys...')
72
73 const enPath = path.join(localesDir, sourceLocale)
74 const enContent = await fs.readFile(enPath, 'utf8')
75 const enJson = JSON.parse(enContent)
76 const keys = Object.keys(enJson)
77
78 const searchDirs = ['app', 'components', 'lib', 'hooks']
79 let allCodeFiles = []
80 for (const dir of searchDirs) {
81 const fullPath = path.join(projectRoot, dir)
82 try {
83 const files = await getFiles(fullPath)
84 allCodeFiles = allCodeFiles.concat(
85 files.filter((f) => /\.(ts|tsx|js|jsx|mjs)$/.test(f)),
86 )
87 } catch (e) {
88 // Directory might not exist
89 }
90 }
91
92 console.log(`📄 Found ${allCodeFiles.length} code files.`)
93 console.log(`🔑 Found ${keys.length} keys in ${sourceLocale}.`)
94
95 const fileContents = await Promise.all(
96 allCodeFiles.map(async (f) => {
97 const content = await fs.readFile(f, 'utf8')
98 return { path: f, content }
99 }),
100 )
101
102 const unusedKeys = []
103
104 for (const key of keys) {
105 let used = false
106 const escapedKey = escapeRegExp(key)
107
108 // Patterns to look for
109 // Using string concatenation for the regex to avoid backtick escaping issues in template literals
110 const patterns = [
111 new RegExp('t(\\.|\\?\\.)' + escapedKey + '\\b'),
112 new RegExp("t\\[['\"`]" + escapedKey + "['\"`]\\]"),
113 new RegExp('\\{\\s*[^}]*\\b' + escapedKey + '\\b[^}]*\\}\\s*=\\s*t\\b'),
114 new RegExp("['\"`]" + escapedKey + "['\"`]")
115 ]
116
117 for (const { content } of fileContents) {
118 if (patterns.some(pattern => pattern.test(content))) {
119 used = true
120 break
121 }
122 }
123
124 if (!used) {
125 unusedKeys.push(key)
126 }
127 }
128
129 if (unusedKeys.length === 0) {
130 console.log('✅ No unused keys found!')
131 process.exit(0)
132 }
133
134 console.log(`\nFound ${unusedKeys.length} potentially unused keys:`)
135 unusedKeys.forEach((key, index) => {
136 console.log(`${index + 1}. ${key}`)
137 })
138
139 console.log('\nOptions:')
140 console.log('- Enter indices to delete (e.g., "1, 3-5, 8")')
141 console.log('- Type "all" to delete everything listed')
142 console.log('- Press Enter to cancel')
143
144 const input = await question('\nYour selection: ')
145 const selectedIndices = parseIndices(input, unusedKeys.length)
146
147 if (selectedIndices.length > 0) {
148 const keysToDelete = selectedIndices.map((i) => unusedKeys[i])
149
150 console.log(`\nSelected ${keysToDelete.length} keys for deletion.`)
151 const confirm = await question('Are you sure you want to proceed? (y/N): ')
152
153 if (confirm.toLowerCase() === 'y') {
154 const localeFiles = (await fs.readdir(localesDir)).filter((f) => f.endsWith('.json'))
155
156 for (const file of localeFiles) {
157 const filePath = path.join(localesDir, file)
158 const content = await fs.readFile(filePath, 'utf8')
159 const json = JSON.parse(content)
160
161 let deletedCount = 0
162 for (const key of keysToDelete) {
163 if (Object.prototype.hasOwnProperty.call(json, key)) {
164 delete json[key]
165 deletedCount++
166 }
167 }
168
169 if (deletedCount > 0) {
170 await fs.writeFile(filePath, JSON.stringify(json, null, 2) + '\n', 'utf8')
171 console.log(`Updated ${file} (removed ${deletedCount} keys)`)
172 }
173 }
174
175 // Check for i18n.lock and warn
176 try {
177 await fs.access(path.join(projectRoot, 'i18n.lock'))
178 console.log('\n⚠️ Detected i18n.lock file. You may need to run your translation tool (e.g., lingo) to resync.')
179 } catch (e) {
180 // No lock file, ignore
181 }
182
183 console.log('\n✨ Cleanup complete!')
184 console.log('Run `pnpm run generate:locales` to update lib/locales.ts')
185 } else {
186 console.log('\nAborted.')
187 }
188 } else {
189 console.log('\nNo keys selected. Aborted.')
190 }
191
192 rl.close()
193}
194
195run().catch((err) => {
196 console.error('Error:', err)
197 process.exit(1)
198})