practicode 0.1.0 → 0.1.2
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/Cargo.lock +1 -1
- package/Cargo.toml +1 -1
- package/README.md +43 -101
- package/THIRD_PARTY_LICENSES.md +58 -0
- package/assets/i18n/en.json +50 -0
- package/assets/i18n/es.json +50 -0
- package/assets/i18n/ja.json +50 -0
- package/assets/i18n/ko.json +50 -0
- package/assets/i18n/zh.json +50 -0
- package/docs/CONTRIBUTING.md +81 -0
- package/package.json +4 -2
- package/scripts/release.sh +69 -0
- package/src/ai.rs +38 -6
- package/src/core.rs +116 -32
- package/src/i18n.rs +45 -0
- package/src/lib.rs +2 -0
- package/src/tui.rs +470 -91
- package/src/update.rs +45 -0
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
current=$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -1)
|
|
5
|
+
npm_current=$(node -p "require('./package.json').version")
|
|
6
|
+
|
|
7
|
+
if [[ "$current" != "$npm_current" ]]; then
|
|
8
|
+
echo "Cargo.toml version ($current) does not match package.json ($npm_current)" >&2
|
|
9
|
+
exit 1
|
|
10
|
+
fi
|
|
11
|
+
|
|
12
|
+
if [[ -n "$(git status --porcelain)" ]]; then
|
|
13
|
+
echo "working tree is dirty; commit or stash changes first" >&2
|
|
14
|
+
exit 1
|
|
15
|
+
fi
|
|
16
|
+
|
|
17
|
+
branch=$(git branch --show-current)
|
|
18
|
+
if [[ "$branch" != "main" ]]; then
|
|
19
|
+
echo "release from main, not $branch" >&2
|
|
20
|
+
exit 1
|
|
21
|
+
fi
|
|
22
|
+
|
|
23
|
+
git fetch origin main --tags
|
|
24
|
+
if [[ "$(git rev-parse HEAD)" != "$(git rev-parse origin/main)" ]]; then
|
|
25
|
+
echo "local main is not synced with origin/main" >&2
|
|
26
|
+
exit 1
|
|
27
|
+
fi
|
|
28
|
+
|
|
29
|
+
echo "Current version: $current"
|
|
30
|
+
version="${1:-}"
|
|
31
|
+
if [[ -z "$version" ]]; then
|
|
32
|
+
read -r -p "Next version: " version
|
|
33
|
+
fi
|
|
34
|
+
|
|
35
|
+
if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
|
36
|
+
echo "version must look like 0.1.1" >&2
|
|
37
|
+
exit 2
|
|
38
|
+
fi
|
|
39
|
+
|
|
40
|
+
node -e '
|
|
41
|
+
const [current, next] = process.argv.slice(1).map(v => v.split(".").map(Number));
|
|
42
|
+
const ok = next[0] > current[0] ||
|
|
43
|
+
(next[0] === current[0] && (next[1] > current[1] ||
|
|
44
|
+
(next[1] === current[1] && next[2] > current[2])));
|
|
45
|
+
process.exit(ok ? 0 : 1);
|
|
46
|
+
' "$current" "$version" || {
|
|
47
|
+
echo "next version must be greater than $current" >&2
|
|
48
|
+
exit 2
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if git rev-parse -q --verify "refs/tags/v$version" >/dev/null; then
|
|
52
|
+
echo "tag v$version already exists locally" >&2
|
|
53
|
+
exit 1
|
|
54
|
+
fi
|
|
55
|
+
if git ls-remote --exit-code --tags origin "v$version" >/dev/null 2>&1; then
|
|
56
|
+
echo "tag v$version already exists on origin" >&2
|
|
57
|
+
exit 1
|
|
58
|
+
fi
|
|
59
|
+
|
|
60
|
+
echo "Releasing v$version"
|
|
61
|
+
VERSION="$version" perl -0pi -e 's/^version = ".*"/version = "$ENV{VERSION}"/m' Cargo.toml
|
|
62
|
+
node -e "const fs=require('fs'); const p=require('./package.json'); p.version=process.env.VERSION; fs.writeFileSync('package.json', JSON.stringify(p, null, 2) + '\n')"
|
|
63
|
+
cargo test
|
|
64
|
+
npm pack --dry-run >/dev/null
|
|
65
|
+
|
|
66
|
+
git add Cargo.toml Cargo.lock package.json
|
|
67
|
+
git commit -m "Release v$version"
|
|
68
|
+
git tag "v$version"
|
|
69
|
+
git push origin main "v$version"
|
package/src/ai.rs
CHANGED
|
@@ -3,13 +3,14 @@ use crate::{
|
|
|
3
3
|
AppState, PROBLEM_NOTES_PATH, Problem, Settings, ensure_submission, normalize_ai_provider,
|
|
4
4
|
render_problem,
|
|
5
5
|
},
|
|
6
|
-
process::{run_capture, sh_quote, shell_process, unique_temp_path},
|
|
6
|
+
process::{run_capture, sh_quote, shell_process, unique_temp_path, which},
|
|
7
7
|
};
|
|
8
8
|
use anyhow::Result;
|
|
9
9
|
use std::{
|
|
10
|
+
env,
|
|
10
11
|
fs::{self, OpenOptions},
|
|
11
12
|
io::Write,
|
|
12
|
-
path::Path,
|
|
13
|
+
path::{Path, PathBuf},
|
|
13
14
|
process::Command,
|
|
14
15
|
time::Duration,
|
|
15
16
|
};
|
|
@@ -39,7 +40,7 @@ pub fn run_ai_prompt(root: &Path, problem: &Problem, settings: &Settings, prompt
|
|
|
39
40
|
|
|
40
41
|
pub fn run_ai_next(root: &Path, state: &AppState, force: bool, request: &str) -> String {
|
|
41
42
|
if state.settings.next_source != "ai" && !force {
|
|
42
|
-
return "AI next is disabled; using local
|
|
43
|
+
return "AI next is disabled; using local problems.".to_string();
|
|
43
44
|
}
|
|
44
45
|
let provider = normalize_ai_provider(&state.settings.ai_provider);
|
|
45
46
|
let command = if state.settings.next_ai_command().trim().is_empty() {
|
|
@@ -78,9 +79,32 @@ pub fn default_ai_next_command(root: &Path, settings: &Settings, request: &str)
|
|
|
78
79
|
}
|
|
79
80
|
}
|
|
80
81
|
|
|
82
|
+
pub fn provider_status(provider: &str) -> String {
|
|
83
|
+
match normalize_ai_provider(provider).as_str() {
|
|
84
|
+
"claude" => {
|
|
85
|
+
if which("claude").is_some() {
|
|
86
|
+
"Claude CLI found.".to_string()
|
|
87
|
+
} else {
|
|
88
|
+
"Claude CLI not found. Install Claude Code or choose /provider codex.".to_string()
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
_ => {
|
|
92
|
+
if which("codex").is_none() {
|
|
93
|
+
return "Codex CLI not found. Install Codex CLI or choose /provider claude."
|
|
94
|
+
.to_string();
|
|
95
|
+
}
|
|
96
|
+
if codex_daemon_path().is_some_and(|path| path.exists()) {
|
|
97
|
+
"Codex CLI found. App-server daemon is available.".to_string()
|
|
98
|
+
} else {
|
|
99
|
+
"Codex CLI found. App-server daemon is not available; practicode will use codex exec directly.".to_string()
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
81
105
|
pub fn default_ai_next_prompt(request: &str) -> String {
|
|
82
106
|
format!(
|
|
83
|
-
"Read AGENTS.md, docs/problem-authoring-notes.md if present, .practicode/problem_notes.md if present, problems/INDEX.md if present, .practicode/problem_bank.json if present, and .practicode/problem-state.json.
|
|
107
|
+
"Read AGENTS.md, docs/problem-authoring-notes.md if present, .practicode/problem_notes.md if present, problems/INDEX.md if present, .practicode/problem_bank.json if present, and .practicode/problem-state.json. Create exactly one new non-duplicate coding practice problem. The built-in 001-hello-world already exists, so do not duplicate it. User request: {}. Make the smallest valid edits: update .practicode/problem_bank.json, one problem directory, problems/INDEX.md, and .practicode/problem-state.json. Do not include the answer in the problem statement.",
|
|
84
108
|
if request.is_empty() {
|
|
85
109
|
"(none)"
|
|
86
110
|
} else {
|
|
@@ -177,9 +201,9 @@ fn run_claude_prompt(root: &Path, settings: &Settings, prompt: &str) -> String {
|
|
|
177
201
|
}
|
|
178
202
|
|
|
179
203
|
fn default_codex_next_command(root: &Path, settings: &Settings, request: &str) -> String {
|
|
180
|
-
let start = "codex app-server daemon start >/dev/null 2>&1 || true";
|
|
204
|
+
let start = "if [ -x \"$HOME/.codex/packages/standalone/current/codex\" ]; then codex app-server daemon start >/dev/null 2>&1 || true; fi";
|
|
181
205
|
let mut exec = format!(
|
|
182
|
-
"codex exec --cd {} --sandbox workspace-write",
|
|
206
|
+
"codex exec --ephemeral --cd {} --sandbox workspace-write",
|
|
183
207
|
sh_quote(&root.display().to_string())
|
|
184
208
|
);
|
|
185
209
|
if let Some(model) = settings.model_arg() {
|
|
@@ -190,6 +214,14 @@ fn default_codex_next_command(root: &Path, settings: &Settings, request: &str) -
|
|
|
190
214
|
format!("{start}; {exec}")
|
|
191
215
|
}
|
|
192
216
|
|
|
217
|
+
fn codex_daemon_path() -> Option<PathBuf> {
|
|
218
|
+
env::var_os("HOME").map(|home| {
|
|
219
|
+
PathBuf::from(home)
|
|
220
|
+
.join(".codex/packages/standalone/current")
|
|
221
|
+
.join(if cfg!(windows) { "codex.exe" } else { "codex" })
|
|
222
|
+
})
|
|
223
|
+
}
|
|
224
|
+
|
|
193
225
|
fn default_claude_next_command(root: &Path, settings: &Settings, request: &str) -> String {
|
|
194
226
|
let mut claude = "claude --permission-mode acceptEdits".to_string();
|
|
195
227
|
if let Some(model) = settings.model_arg() {
|
package/src/core.rs
CHANGED
|
@@ -10,7 +10,7 @@ use std::{
|
|
|
10
10
|
};
|
|
11
11
|
|
|
12
12
|
pub const LANGUAGES: &[&str] = &["python", "ts", "java", "rust"];
|
|
13
|
-
pub
|
|
13
|
+
pub use crate::i18n::{UI_LANGUAGES, normalize_ui_language, ui_text};
|
|
14
14
|
pub const THEMES: &[&str] = &["dark", "light"];
|
|
15
15
|
pub const AI_PROVIDERS: &[&str] = &["codex", "claude"];
|
|
16
16
|
pub const BANK_PATH: &str = ".practicode/problem_bank.json";
|
|
@@ -120,7 +120,7 @@ pub fn default_language() -> String {
|
|
|
120
120
|
}
|
|
121
121
|
|
|
122
122
|
pub fn default_ui_language() -> String {
|
|
123
|
-
"
|
|
123
|
+
"en".to_string()
|
|
124
124
|
}
|
|
125
125
|
|
|
126
126
|
pub fn default_theme() -> String {
|
|
@@ -163,20 +163,34 @@ pub fn starter_problem() -> Problem {
|
|
|
163
163
|
slug: "hello-world".to_string(),
|
|
164
164
|
difficulty: "easy".to_string(),
|
|
165
165
|
topics: vec!["io".to_string()],
|
|
166
|
-
title:
|
|
167
|
-
|
|
168
|
-
"ko",
|
|
169
|
-
"
|
|
170
|
-
"
|
|
171
|
-
"
|
|
172
|
-
),
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
"ko",
|
|
176
|
-
"`Hello, World!`
|
|
177
|
-
"
|
|
178
|
-
"
|
|
179
|
-
),
|
|
166
|
+
title: localized_map(&[
|
|
167
|
+
("en", "Hello World"),
|
|
168
|
+
("ko", "Hello World"),
|
|
169
|
+
("ja", "Hello World"),
|
|
170
|
+
("zh", "Hello World"),
|
|
171
|
+
("es", "Hello World"),
|
|
172
|
+
]),
|
|
173
|
+
statement: localized_map(&[
|
|
174
|
+
("en", "Print exactly `Hello, World!` to stdout."),
|
|
175
|
+
("ko", "표준 출력으로 정확히 `Hello, World!`를 출력하세요."),
|
|
176
|
+
("ja", "標準出力に正確に `Hello, World!` を出力してください。"),
|
|
177
|
+
("zh", "向标准输出准确打印 `Hello, World!`。"),
|
|
178
|
+
("es", "Imprime exactamente `Hello, World!` en stdout."),
|
|
179
|
+
]),
|
|
180
|
+
input: localized_map(&[
|
|
181
|
+
("en", "No input."),
|
|
182
|
+
("ko", "입력은 없습니다."),
|
|
183
|
+
("ja", "入力はありません。"),
|
|
184
|
+
("zh", "没有输入。"),
|
|
185
|
+
("es", "No hay entrada."),
|
|
186
|
+
]),
|
|
187
|
+
output: localized_map(&[
|
|
188
|
+
("en", "One line: `Hello, World!`"),
|
|
189
|
+
("ko", "`Hello, World!` 한 줄"),
|
|
190
|
+
("ja", "1行: `Hello, World!`"),
|
|
191
|
+
("zh", "一行: `Hello, World!`"),
|
|
192
|
+
("es", "Una linea: `Hello, World!`"),
|
|
193
|
+
]),
|
|
180
194
|
examples: vec![IoCase {
|
|
181
195
|
input: String::new(),
|
|
182
196
|
output: "Hello, World!\n".to_string(),
|
|
@@ -210,6 +224,13 @@ pub fn map2(k1: &str, v1: &str, k2: &str, v2: &str) -> HashMap<String, String> {
|
|
|
210
224
|
])
|
|
211
225
|
}
|
|
212
226
|
|
|
227
|
+
pub fn localized_map(entries: &[(&str, &str)]) -> HashMap<String, String> {
|
|
228
|
+
entries
|
|
229
|
+
.iter()
|
|
230
|
+
.map(|(key, value)| ((*key).to_string(), (*value).to_string()))
|
|
231
|
+
.collect()
|
|
232
|
+
}
|
|
233
|
+
|
|
213
234
|
pub fn load_bank(root: &Path) -> Result<Vec<Problem>> {
|
|
214
235
|
let path = root.join(BANK_PATH);
|
|
215
236
|
if path.exists() {
|
|
@@ -339,9 +360,7 @@ pub fn save_state(root: &Path, state: &AppState) -> Result<()> {
|
|
|
339
360
|
|
|
340
361
|
pub fn normalize_settings(settings: &mut Settings) {
|
|
341
362
|
settings.language = normalize_language(&settings.language);
|
|
342
|
-
|
|
343
|
-
settings.ui_language = "ko".to_string();
|
|
344
|
-
}
|
|
363
|
+
settings.ui_language = normalize_ui_language(&settings.ui_language);
|
|
345
364
|
if !THEMES.contains(&settings.theme.as_str()) {
|
|
346
365
|
settings.theme = "dark".to_string();
|
|
347
366
|
}
|
|
@@ -381,9 +400,10 @@ pub fn normalize_ai_provider(provider: &str) -> String {
|
|
|
381
400
|
}
|
|
382
401
|
|
|
383
402
|
pub fn localized(map: &HashMap<String, String>, lang: &str) -> String {
|
|
384
|
-
|
|
385
|
-
|
|
403
|
+
let lang = normalize_ui_language(lang);
|
|
404
|
+
map.get(lang.as_str())
|
|
386
405
|
.or_else(|| map.get("en"))
|
|
406
|
+
.or_else(|| map.get("ko"))
|
|
387
407
|
.or_else(|| map.values().next())
|
|
388
408
|
.cloned()
|
|
389
409
|
.unwrap_or_default()
|
|
@@ -415,20 +435,19 @@ pub fn ensure_submission(root: &Path, problem: &Problem, settings: &Settings) ->
|
|
|
415
435
|
}
|
|
416
436
|
|
|
417
437
|
pub fn render_problem(problem: &Problem, ui_language: &str) -> String {
|
|
418
|
-
let lang =
|
|
419
|
-
ui_language
|
|
420
|
-
} else {
|
|
421
|
-
"ko"
|
|
422
|
-
};
|
|
438
|
+
let lang = normalize_ui_language(ui_language);
|
|
423
439
|
let examples = problem
|
|
424
440
|
.examples
|
|
425
441
|
.iter()
|
|
426
442
|
.enumerate()
|
|
427
443
|
.map(|(index, case)| {
|
|
428
444
|
format!(
|
|
429
|
-
"###
|
|
445
|
+
"### {} {}\n\n{}\n\n{}\n\n{}\n\n{}",
|
|
446
|
+
ui_text(&lang, "example"),
|
|
430
447
|
index + 1,
|
|
448
|
+
ui_text(&lang, "input"),
|
|
431
449
|
fenced_text(&case.input),
|
|
450
|
+
ui_text(&lang, "output"),
|
|
432
451
|
fenced_text(&case.output)
|
|
433
452
|
)
|
|
434
453
|
})
|
|
@@ -440,17 +459,82 @@ pub fn render_problem(problem: &Problem, ui_language: &str) -> String {
|
|
|
440
459
|
.map(|(number, _)| number)
|
|
441
460
|
.unwrap_or(&problem.id);
|
|
442
461
|
format!(
|
|
443
|
-
"# {number}. {}\n\
|
|
444
|
-
localized(&problem.title, lang),
|
|
462
|
+
"# {number}. {}\n\n{}: {}\n{}: {}\n\n{}\n\n## {}\n\n{}\n\n## {}\n\n{}\n\n## {}\n\n{}",
|
|
463
|
+
localized(&problem.title, &lang),
|
|
464
|
+
ui_text(&lang, "difficulty"),
|
|
445
465
|
problem.difficulty,
|
|
466
|
+
ui_text(&lang, "topics"),
|
|
446
467
|
problem.topics.join(", "),
|
|
447
|
-
localized(&problem.statement, lang),
|
|
448
|
-
|
|
449
|
-
localized(&problem.
|
|
468
|
+
localized(&problem.statement, &lang),
|
|
469
|
+
ui_text(&lang, "input"),
|
|
470
|
+
localized(&problem.input, &lang),
|
|
471
|
+
ui_text(&lang, "output"),
|
|
472
|
+
localized(&problem.output, &lang),
|
|
473
|
+
ui_text(&lang, "examples"),
|
|
450
474
|
examples
|
|
451
475
|
)
|
|
452
476
|
}
|
|
453
477
|
|
|
478
|
+
pub fn render_problem_tui(problem: &Problem, ui_language: &str) -> String {
|
|
479
|
+
let lang = normalize_ui_language(ui_language);
|
|
480
|
+
let number = problem
|
|
481
|
+
.id
|
|
482
|
+
.split_once('-')
|
|
483
|
+
.map(|(number, _)| number)
|
|
484
|
+
.unwrap_or(&problem.id);
|
|
485
|
+
let mut lines = vec![
|
|
486
|
+
format!("{number}. {}", localized(&problem.title, &lang)),
|
|
487
|
+
format!(
|
|
488
|
+
"{}: {} {}: {}",
|
|
489
|
+
ui_text(&lang, "difficulty"),
|
|
490
|
+
problem.difficulty,
|
|
491
|
+
ui_text(&lang, "topics"),
|
|
492
|
+
problem.topics.join(", ")
|
|
493
|
+
),
|
|
494
|
+
String::new(),
|
|
495
|
+
localized(&problem.statement, &lang),
|
|
496
|
+
];
|
|
497
|
+
push_tui_section(
|
|
498
|
+
&mut lines,
|
|
499
|
+
ui_text(&lang, "input"),
|
|
500
|
+
&localized(&problem.input, &lang),
|
|
501
|
+
);
|
|
502
|
+
push_tui_section(
|
|
503
|
+
&mut lines,
|
|
504
|
+
ui_text(&lang, "output"),
|
|
505
|
+
&localized(&problem.output, &lang),
|
|
506
|
+
);
|
|
507
|
+
lines.push(String::new());
|
|
508
|
+
lines.push(ui_text(&lang, "examples").to_string());
|
|
509
|
+
for (index, case) in problem.examples.iter().enumerate() {
|
|
510
|
+
lines.push(format!(" {} {}", ui_text(&lang, "example"), index + 1));
|
|
511
|
+
lines.push(format!(" {}:", ui_text(&lang, "input")));
|
|
512
|
+
push_case_text(&mut lines, &case.input);
|
|
513
|
+
lines.push(format!(" {}:", ui_text(&lang, "output")));
|
|
514
|
+
push_case_text(&mut lines, &case.output);
|
|
515
|
+
}
|
|
516
|
+
lines.join("\n").trim_end().to_string()
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
fn push_tui_section(lines: &mut Vec<String>, title: &str, body: &str) {
|
|
520
|
+
lines.push(String::new());
|
|
521
|
+
lines.push(title.to_string());
|
|
522
|
+
for line in body.trim_end().lines() {
|
|
523
|
+
lines.push(format!(" {line}"));
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
fn push_case_text(lines: &mut Vec<String>, body: &str) {
|
|
528
|
+
let body = body.trim_end();
|
|
529
|
+
if body.is_empty() {
|
|
530
|
+
lines.push(" <empty>".to_string());
|
|
531
|
+
} else {
|
|
532
|
+
for line in body.lines() {
|
|
533
|
+
lines.push(format!(" {line}"));
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
454
538
|
pub fn fenced_text(value: &str) -> String {
|
|
455
539
|
let mut body = value.to_string();
|
|
456
540
|
if !body.ends_with('\n') {
|
package/src/i18n.rs
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
use std::{collections::HashMap, sync::OnceLock};
|
|
2
|
+
|
|
3
|
+
pub const UI_LANGUAGES: &[&str] = &["en", "ko", "ja", "zh", "es"];
|
|
4
|
+
|
|
5
|
+
static EN: OnceLock<HashMap<String, String>> = OnceLock::new();
|
|
6
|
+
static KO: OnceLock<HashMap<String, String>> = OnceLock::new();
|
|
7
|
+
static JA: OnceLock<HashMap<String, String>> = OnceLock::new();
|
|
8
|
+
static ZH: OnceLock<HashMap<String, String>> = OnceLock::new();
|
|
9
|
+
static ES: OnceLock<HashMap<String, String>> = OnceLock::new();
|
|
10
|
+
|
|
11
|
+
pub fn normalize_ui_language(language: &str) -> String {
|
|
12
|
+
let lower = language.trim().to_lowercase();
|
|
13
|
+
let short = lower
|
|
14
|
+
.split(['-', '_'])
|
|
15
|
+
.next()
|
|
16
|
+
.filter(|value| !value.is_empty())
|
|
17
|
+
.unwrap_or("en");
|
|
18
|
+
if UI_LANGUAGES.contains(&short) {
|
|
19
|
+
short.to_string()
|
|
20
|
+
} else {
|
|
21
|
+
"en".to_string()
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
pub fn ui_text(lang: &str, key: &str) -> &'static str {
|
|
26
|
+
catalog(&normalize_ui_language(lang))
|
|
27
|
+
.get(key)
|
|
28
|
+
.or_else(|| catalog("en").get(key))
|
|
29
|
+
.map(String::as_str)
|
|
30
|
+
.unwrap_or("")
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
fn catalog(lang: &str) -> &'static HashMap<String, String> {
|
|
34
|
+
match lang {
|
|
35
|
+
"ko" => KO.get_or_init(|| load(include_str!("../assets/i18n/ko.json"))),
|
|
36
|
+
"ja" => JA.get_or_init(|| load(include_str!("../assets/i18n/ja.json"))),
|
|
37
|
+
"zh" => ZH.get_or_init(|| load(include_str!("../assets/i18n/zh.json"))),
|
|
38
|
+
"es" => ES.get_or_init(|| load(include_str!("../assets/i18n/es.json"))),
|
|
39
|
+
_ => EN.get_or_init(|| load(include_str!("../assets/i18n/en.json"))),
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
fn load(text: &str) -> HashMap<String, String> {
|
|
44
|
+
serde_json::from_str(text).expect("valid i18n catalog")
|
|
45
|
+
}
|
package/src/lib.rs
CHANGED