Merge pull request #418 from neilcawse/fix/numfmt-text
Numfmt: fraction literals and zero fractions, `?` pads thousands separator, TEXT booleans through text section
This commit is contained in:
commit
e2bcb712fd
|
|
@ -295,7 +295,8 @@ pub fn format_value(v: &Value, fmt: &NumberFormat, sys: DateSystem) -> Formatted
|
|||
}
|
||||
|
||||
/// The `TEXT()` worksheet function: formats a value with a format code. Numeric text is
|
||||
/// converted to a number first; other text goes through the text section (or is returned as is).
|
||||
/// converted to a number first; other text and booleans (as `TRUE`/`FALSE`) go through the text
|
||||
/// section, or are returned as is.
|
||||
pub fn text_function(v: &Value, code: &str, sys: DateSystem) -> Result<String, CellError> {
|
||||
if code.is_empty() {
|
||||
return Ok(String::new());
|
||||
|
|
@ -305,7 +306,7 @@ pub fn text_function(v: &Value, code: &str, sys: DateSystem) -> Result<String, C
|
|||
Value::Error(e) => return Err(e),
|
||||
Value::Empty => 0.0,
|
||||
Value::Number(n) => n,
|
||||
Value::Bool(b) => return Ok(if b { "TRUE" } else { "FALSE" }.into()),
|
||||
Value::Bool(b) => return Ok(fmt.format_text(if b { "TRUE" } else { "FALSE" }).text),
|
||||
Value::Text(t) => match gridcraft_core::parse::parse_number_text(&t) {
|
||||
Some(n) => n,
|
||||
None => return Ok(fmt.format_text(&t).text),
|
||||
|
|
|
|||
|
|
@ -555,7 +555,9 @@ fn analyze(raw: Raw) -> Section {
|
|||
}
|
||||
}
|
||||
Tok::Slash => {
|
||||
let next_ok = matches!(toks.get(i + 1), Some(Tok::Digit(_) | Tok::FixedDenom(_)));
|
||||
// Literal text may sit between the bar and the denominator (`#/ #`, `# ?=/=?`).
|
||||
let next_ok = matches!(toks.get(i + 1), Some(Tok::FixedDenom(_)))
|
||||
|| matches!(toks.get(i + 1..).and_then(|s| s.iter().find(|t| !matches!(t, Tok::Lit(_)))), Some(Tok::Digit(_)));
|
||||
let has_exp = toks.iter().any(|t| matches!(t, Tok::Exp { .. }));
|
||||
if !fraction_slash && seen_digit && next_ok && !seen_point && !has_exp {
|
||||
fraction_slash = true;
|
||||
|
|
|
|||
|
|
@ -138,7 +138,8 @@ pub(crate) fn render_date(sec: &Section, v: f64, sys: DateSystem) -> Option<Out>
|
|||
}
|
||||
|
||||
/// Assigns integer digits right-to-left to the placeholders at `idxs`; the leftmost placeholder
|
||||
/// takes all remaining digits.
|
||||
/// takes all remaining digits. `force_zero` shows `0` in the units placeholder when there are no
|
||||
/// digits. A blank `?` pads with a space, and so does the thousands separator it would carry.
|
||||
fn fill_int(toks: &[Tok], idxs: &[usize], digits: &str, thousands: bool, force_zero: bool, pieces: &mut [String]) {
|
||||
let ds: Vec<char> = digits.chars().collect();
|
||||
let mut rem = ds.len();
|
||||
|
|
@ -162,8 +163,14 @@ fn fill_int(toks: &[Tok], idxs: &[usize], digits: &str, thousands: bool, force_z
|
|||
if take == 0 {
|
||||
match ph {
|
||||
'0' => push_digit('0', &mut rev, &mut pos),
|
||||
_ if force_zero && leftmost && pos == 0 => push_digit('0', &mut rev, &mut pos),
|
||||
'?' => rev.push(' '),
|
||||
_ if force_zero && k == 0 => push_digit('0', &mut rev, &mut pos),
|
||||
'?' => {
|
||||
if thousands && pos > 0 && pos.is_multiple_of(3) {
|
||||
rev.push(' ');
|
||||
}
|
||||
rev.push(' ');
|
||||
pos += 1;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
} else {
|
||||
|
|
@ -235,11 +242,14 @@ pub(crate) fn render_number(sec: &Section, v: f64) -> Out {
|
|||
}
|
||||
let toks = &sec.toks;
|
||||
let n = toks.len();
|
||||
// Pieces replace the token's own output; `before` is inserted ahead of a token.
|
||||
// Pieces replace the token's own output; `before` is inserted ahead of a token; `lits`
|
||||
// replaces a literal's text (fractions blank the literals of a hidden part).
|
||||
let mut pieces: Vec<String> = vec![String::new(); n];
|
||||
let mut before: Vec<String> = vec![String::new(); n + 1];
|
||||
let mut lits: Vec<Option<String>> = Vec::new();
|
||||
if sec.fraction {
|
||||
render_fraction(sec, x, &mut pieces);
|
||||
lits = vec![None; n];
|
||||
render_fraction(sec, x, &mut pieces, &mut lits);
|
||||
} else {
|
||||
let point = toks.iter().position(|t| matches!(t, Tok::Point));
|
||||
let exp = toks.iter().position(|t| matches!(t, Tok::Exp { .. }));
|
||||
|
|
@ -321,7 +331,7 @@ pub(crate) fn render_number(sec: &Section, v: f64) -> Out {
|
|||
out.push(b);
|
||||
}
|
||||
match t {
|
||||
Tok::Lit(s) => out.push(s),
|
||||
Tok::Lit(s) => out.push(lits.get(i).and_then(Option::as_deref).unwrap_or(s)),
|
||||
Tok::Percent => out.push("%"),
|
||||
Tok::Fill(c) => out.fill(*c),
|
||||
Tok::At | Tok::General | Tok::Date(_) | Tok::Comma => {}
|
||||
|
|
@ -370,29 +380,77 @@ fn approximate(x: f64, max_den: u64) -> (u64, u64) {
|
|||
if err_b < err_a { (pb, qb) } else { (p1, q1) }
|
||||
}
|
||||
|
||||
fn render_fraction(sec: &Section, x: f64, pieces: &mut [String]) {
|
||||
/// Display width of a token in a fraction section, for blanking a hidden part with spaces.
|
||||
fn shown_width(t: &Tok) -> usize {
|
||||
match t {
|
||||
Tok::Lit(s) | Tok::FixedDenom(s) => s.chars().count(),
|
||||
Tok::Digit(_) | Tok::Slash => 1,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Replaces the literals in `range` with spaces (`spaces`) or nothing; placeholders and the bar
|
||||
/// in it get the same treatment through `pieces`.
|
||||
fn blank_range(toks: &[Tok], range: std::ops::Range<usize>, spaces: bool, pieces: &mut [String], lits: &mut [Option<String>]) {
|
||||
for i in range {
|
||||
let Some(t) = toks.get(i) else { break };
|
||||
let s = if spaces { " ".repeat(shown_width(t)) } else { String::new() };
|
||||
match t {
|
||||
Tok::Lit(_) => {
|
||||
if let Some(l) = lits.get_mut(i) {
|
||||
*l = Some(s);
|
||||
}
|
||||
}
|
||||
Tok::Digit(_) | Tok::Slash | Tok::FixedDenom(_) => {
|
||||
if let Some(p) = pieces.get_mut(i) {
|
||||
*p = s;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A fraction section is `[whole] [sep] num [lit] / [lit] den`: the numerator is the run of
|
||||
/// placeholders ending at the last one before the bar, the whole part is every placeholder
|
||||
/// before it (literals between them stay), `sep` the literals between the two parts.
|
||||
///
|
||||
/// Observed Excel behaviour: when the whole part shows no digit its separator disappears (or
|
||||
/// becomes spaces when the whole part or the numerator has a `?`); when the fraction is zero and
|
||||
/// the numerator has no `0` placeholder, the separator, numerator, bar and denominator disappear
|
||||
/// (or become spaces when any part has a `?`) and the whole part shows at least `0`.
|
||||
fn render_fraction(sec: &Section, x: f64, pieces: &mut [String], lits: &mut [Option<String>]) {
|
||||
let toks = &sec.toks;
|
||||
let is_digit = |i: usize| matches!(toks.get(i), Some(Tok::Digit(_)));
|
||||
let Some(slash) = toks.iter().position(|t| matches!(t, Tok::Slash)) else { return };
|
||||
let mut num_start = slash;
|
||||
while num_start > 0 && matches!(toks.get(num_start - 1), Some(Tok::Digit(_))) {
|
||||
let Some(num_last) = (0..slash).rev().find(|&i| is_digit(i)) else { return };
|
||||
let mut num_start = num_last;
|
||||
while num_start > 0 && is_digit(num_start - 1) {
|
||||
num_start -= 1;
|
||||
}
|
||||
let whole_idx = digit_indices(toks, 0..num_start);
|
||||
let num_idx = digit_indices(toks, num_start..slash);
|
||||
let mut den_end = slash + 1;
|
||||
let fixed_den: Option<u64> = match toks.get(slash + 1) {
|
||||
Some(Tok::FixedDenom(s)) => {
|
||||
den_end += 1;
|
||||
Some(s.parse::<u64>().unwrap_or(u64::MAX).clamp(1, 1_000_000_000))
|
||||
let num_idx = digit_indices(toks, num_start..num_last + 1);
|
||||
let sep_start = whole_idx.last().map_or(num_start, |&w| w + 1);
|
||||
let (fixed_den, den_start): (Option<u64>, usize) = match toks.get(slash + 1) {
|
||||
Some(Tok::FixedDenom(s)) => (Some(s.parse::<u64>().unwrap_or(u64::MAX).clamp(1, 1_000_000_000)), slash + 1),
|
||||
_ => {
|
||||
let mut i = slash + 1;
|
||||
while matches!(toks.get(i), Some(Tok::Lit(_))) {
|
||||
i += 1;
|
||||
}
|
||||
(None, i)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if fixed_den.is_none() {
|
||||
while matches!(toks.get(den_end), Some(Tok::Digit(_))) {
|
||||
let mut den_end = den_start;
|
||||
if fixed_den.is_some() {
|
||||
den_end += 1;
|
||||
} else {
|
||||
while is_digit(den_end) {
|
||||
den_end += 1;
|
||||
}
|
||||
}
|
||||
let den_idx = digit_indices(toks, slash + 1..den_end);
|
||||
let den_idx = digit_indices(toks, den_start..den_end);
|
||||
let has_q = |idx: &[usize]| idx.iter().any(|&i| toks.get(i) == Some(&Tok::Digit('?')));
|
||||
let has_whole = !whole_idx.is_empty();
|
||||
let (mut whole, f) = if has_whole { (x.floor(), x - x.floor()) } else { (0.0, x) };
|
||||
let (mut num, den) = match fixed_den {
|
||||
|
|
@ -407,39 +465,39 @@ fn render_fraction(sec: &Section, x: f64, pieces: &mut [String]) {
|
|||
num %= den;
|
||||
}
|
||||
let whole_s = if whole >= 1.0 { fixed(whole, 0).0 } else { String::new() };
|
||||
let blank = has_whole && num == 0;
|
||||
fill_int(toks, &whole_idx, &whole_s, sec.thousands, blank, pieces);
|
||||
let num_s = num.to_string();
|
||||
let den_s = den.to_string();
|
||||
if blank {
|
||||
let width = |idx: &[usize]| idx.len();
|
||||
let nw = width(&num_idx).max(1);
|
||||
if let Some(&first) = num_idx.first()
|
||||
&& let Some(p) = pieces.get_mut(first)
|
||||
{
|
||||
*p = " ".repeat(nw);
|
||||
}
|
||||
if let Some(p) = pieces.get_mut(slash) {
|
||||
*p = " ".into();
|
||||
}
|
||||
let dw = if fixed_den.is_some() { den_s.len() } else { den_idx.len() };
|
||||
if let Some(p) = pieces.get_mut(slash + 1) {
|
||||
*p = " ".repeat(dw);
|
||||
}
|
||||
// Literals between the whole part and the numerator stay as they are.
|
||||
let num_zero_ph = num_idx.iter().any(|&i| toks.get(i) == Some(&Tok::Digit('0')));
|
||||
if has_whole && num == 0 && !num_zero_ph {
|
||||
fill_int(toks, &whole_idx, &whole_s, sec.thousands, true, pieces);
|
||||
let spaces = has_q(&whole_idx) || has_q(&num_idx) || has_q(&den_idx);
|
||||
blank_range(toks, sep_start..den_end, spaces, pieces, lits);
|
||||
return;
|
||||
}
|
||||
fill_int(toks, &num_idx, &num_s, false, true, pieces);
|
||||
if has_whole {
|
||||
// A zero value shows `0` in a `?` or `0` units place, nothing in a `#` one.
|
||||
let units_hash = whole_idx.last().is_some_and(|&i| toks.get(i) == Some(&Tok::Digit('#')));
|
||||
let force = whole_s.is_empty() && num == 0 && !units_hash;
|
||||
fill_int(toks, &whole_idx, &whole_s, sec.thousands, force, pieces);
|
||||
let whole_shown = whole_idx.iter().any(|&i| pieces.get(i).is_some_and(|p| p.chars().any(|c| c.is_ascii_digit())));
|
||||
if !whole_shown {
|
||||
let spaces = has_q(&whole_idx) || has_q(&num_idx);
|
||||
blank_range(toks, sep_start..num_start, spaces, pieces, lits);
|
||||
}
|
||||
}
|
||||
fill_int(toks, &num_idx, &num.to_string(), false, true, pieces);
|
||||
if let Some(p) = pieces.get_mut(slash) {
|
||||
*p = "/".into();
|
||||
}
|
||||
let den_s = den.to_string();
|
||||
if fixed_den.is_some() {
|
||||
if let Some(p) = pieces.get_mut(slash + 1) {
|
||||
if let Some(p) = pieces.get_mut(den_start) {
|
||||
*p = den_s;
|
||||
}
|
||||
} else {
|
||||
// Denominator digits are left aligned; `?` pads with spaces after them.
|
||||
let dc: Vec<char> = den_s.chars().collect();
|
||||
// Denominator digits are zero padded to the `0` placeholders, then left aligned; `?`
|
||||
// pads with spaces after them.
|
||||
let zeros = den_idx.iter().filter(|&&i| toks.get(i) == Some(&Tok::Digit('0'))).count();
|
||||
let padded = format!("{den_s:0>zeros$}");
|
||||
let dc: Vec<char> = padded.chars().collect();
|
||||
let count = den_idx.len();
|
||||
for (k, &ti) in den_idx.iter().enumerate() {
|
||||
let s = if k + 1 == count {
|
||||
|
|
|
|||
|
|
@ -122,6 +122,82 @@ fn fractions() {
|
|||
assert_eq!(f("# ???/???", 0.5), " 1/2 ");
|
||||
}
|
||||
|
||||
/// Fraction sections with literal text, as Excel renders them (observed `TEXT()` results).
|
||||
#[test]
|
||||
fn fractions_with_literals() {
|
||||
let cases: &[(&str, f64, &str)] = &[
|
||||
// Literals between the parts and around the bar.
|
||||
(r"|#\:#/#|", 23.75, "|23:3/4|"),
|
||||
(r"|#\:#=/=#|", 23.75, "|23:3=/=4|"),
|
||||
(r"|#\:#=/=#|", -23.75, "-|23:3=/=4|"),
|
||||
(r"|#\:? ?=/=#|", 23.75, "|2:3 3=/=4|"),
|
||||
(r"|#-#-#\:#/#|", 23.75, "|-2-3:3/4|"),
|
||||
// A whole part without digits drops its separator (spaces next to a `?`).
|
||||
(r"|#\:#/#|", 0.75, "|3/4|"),
|
||||
(r"|#\:#/#|", -0.75, "-|3/4|"),
|
||||
(r"|#-#-#\:#/#|", 0.75, "|--3/4|"),
|
||||
(r"|#\:#=/=#|", 0.75, "|3=/=4|"),
|
||||
(r"|#\:0=/=?|", 0.75, "|3=/=4|"),
|
||||
(r"|#\:?=/=#|", 0.75, "| 3=/=4|"),
|
||||
(r"|?\:#=/=#|", 0.75, "| 3=/=4|"),
|
||||
(r"|?\:0=/=0|", -0.75, "-| 3=/=4|"),
|
||||
(r"|?\:?0=/=#|", 0.75, "| 3=/=4|"),
|
||||
(r"|0\:#=/=#|", 0.75, "|0:3=/=4|"),
|
||||
// A zero fraction hides separator, numerator, bar and denominator (spaces if any `?`).
|
||||
(r"|#\:#/#|", 0.0, "|0|"),
|
||||
(r"|#\:#/#|", 1.0, "|1|"),
|
||||
(r"|#\:#/#|", -1.0, "-|1|"),
|
||||
(r"|#-#-#\:#/#|", 0.0, "|--0|"),
|
||||
(r"|#-#-#\:#/#|", 1.0, "|--1|"),
|
||||
(r"|#\:#=/=0|", 0.0, "|0|"),
|
||||
(r"|0\:#=/=#|", 1.0, "|1|"),
|
||||
(r"|#\:#=/=?|", 0.0, "|0 |"),
|
||||
(r"|#\:?=/=0|", 1.0, "|1 |"),
|
||||
(r"|?\:#=/=#|", 0.0, "|0 |"),
|
||||
(r"|?\:#=/=#|", -1.0, "-|1 |"),
|
||||
(r"|#\:? ?=/=#|", 0.0, "|:0 |"),
|
||||
(r"|#\:? ?=/=?|", 1.0, "|:1 |"),
|
||||
// ... unless the numerator has a `0` placeholder: then it shows 0/1.
|
||||
(r"|#\:0=/=#|", 0.0, "|0=/=1|"),
|
||||
(r"|?\:0=/=?|", 0.0, "|0:0=/=1|"),
|
||||
(r"|0\:0=/=0|", 0.0, "|0:0=/=1|"),
|
||||
(r"|#\:0=/=#|", 1.0, "|1:0=/=1|"),
|
||||
(r"|?\:0=/=#|", -1.0, "-|1:0=/=1|"),
|
||||
(r"|#\:? ?0#/000", 0.0, "|:0 00/001"),
|
||||
(r"|#\:? ?0#/000", 1.0, "|:1 00/001"),
|
||||
// `0` denominator placeholders zero pad on the left.
|
||||
(r"|#\:? ?0#/000", 23.75, "|2:3 03/004"),
|
||||
(r"|#\:? ?#/#|", 23.75, "|2:3 3/4|"),
|
||||
// Literal text before the bar: still an improper fraction.
|
||||
(r"|#_#/#|", 3.75, "|15 /4|"),
|
||||
(r"|#_#/#|", -0.75, "-|3 /4|"),
|
||||
(r"|#_#/#|", 0.0, "|0 /1|"),
|
||||
(r"|#_#/#|", -1.0, "-|1 /1|"),
|
||||
(r"|#_#=/=?|", 3.75, "|15 =/=4|"),
|
||||
(r"|#_?=/=0|", 0.0, "|0 =/=1|"),
|
||||
(r"|#_?=/=?|", 1.0, "|1 =/=1|"),
|
||||
];
|
||||
for &(code, v, want) in cases {
|
||||
assert_eq!(f(code, v), want, "{code} with {v}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn question_mark_pads_thousands_separator() {
|
||||
let cases: &[(&str, f64, &str)] = &[
|
||||
("?,???????", 1234567.0, " 1,234,567"),
|
||||
("?,????????", 1234567.0, " 1,234,567"),
|
||||
("?,?????????", 1234567.0, " 1,234,567"),
|
||||
("?,?????????", -1234567.0, "- 1,234,567"),
|
||||
("?,??????", 1234567.0, "1,234,567"),
|
||||
("?,????", 12345.0, "12,345"),
|
||||
("0,000000000", 1234567.0, "0,001,234,567"),
|
||||
];
|
||||
for &(code, v, want) in cases {
|
||||
assert_eq!(f(code, v), want, "{code} with {v}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn literals_and_fill() {
|
||||
assert_eq!(f("\"$\"#,##0.00", 3.0), "$3.00");
|
||||
|
|
@ -264,6 +340,21 @@ fn text_fn() {
|
|||
assert_eq!(text_function(&Value::Number(5.0), "", S), Ok("".into()));
|
||||
assert_eq!(text_function(&Value::Number(-1.0), "m/d/yyyy", S), Err(CellError::Value));
|
||||
assert_eq!(text_function(&Value::Bool(true), "0", S), Ok("TRUE".into()));
|
||||
// Booleans go through the text section as TRUE/FALSE (observed Excel results).
|
||||
let cases: &[(bool, &str, &str)] = &[
|
||||
(true, ";;;@", "TRUE"),
|
||||
(true, ";;;\"hi\"", "hi"),
|
||||
(true, ";;;\"h\"\"I\"", "hI"),
|
||||
(false, ";;;\\h\\i", "hi"),
|
||||
(true, ";;;\\@@\\@", "@TRUE@"),
|
||||
(false, ";;;-@-", "-FALSE-"),
|
||||
(true, ";;;@-", "TRUE-"),
|
||||
(false, ";;;-@-@-", "-FALSE-FALSE-"),
|
||||
(true, "0.00", "TRUE"),
|
||||
];
|
||||
for &(b, code, want) in cases {
|
||||
assert_eq!(text_function(&Value::Bool(b), code, S), Ok(want.into()), "{code} with {b}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Deterministic xorshift for fuzzing.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Function parity: worksheet functions and formulas
|
||||
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (ODDFPRICE, ODDFYIELD, ODDLPRICE, ODDLYIELD added) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (TEXT and number formats: fraction literals, `?` thousands padding, booleans through the text section) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
|
||||
|
||||
Excel's worksheet functions, category by category, against GridCraft's function library. Part of
|
||||
[target-app-parity.md](target-app-parity.md); gaps feed [gaps.md](gaps.md).
|
||||
|
|
@ -95,6 +95,12 @@ reports keep finding edge cases: MIN() with no arguments (#138), COUNTBLANK past
|
|||
10-09). Statistical distributions and financial functions with iterative solvers (RATE, IRR,
|
||||
XIRR, YIELD) are the highest-risk for last-digit differences.
|
||||
|
||||
TEXT() and cell display share `crates/numfmt`; checked against Excel-cached results in
|
||||
number- and text-format test workbooks (352 TEXT cells), all match:
|
||||
fraction sections with literal text (`#\:#=/=#`, `#_#/#`), zero fractions, zero-padded
|
||||
denominators, `?` placeholders padding the thousands separator, and booleans through the text
|
||||
section (`TEXT(TRUE,";;;\@@\@")` = `@TRUE@`).
|
||||
|
||||
Work to close (16–26 h in total): the in-scope missing functions (10–16 h with the table above),
|
||||
an oracle corpus of Excel-computed results per function (owner runs Excel on generated
|
||||
workbooks; results checked in as data, not Excel files) and fixing what it finds (6–10 h).
|
||||
|
|
@ -103,6 +109,7 @@ workbooks; results checked in as data, not Excel files) and fixing what it finds
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | minor | TEXT / number formats fixed against Excel-cached results: fraction literals and zero fractions, `0` denominators zero padded, `?` pads the thousands separator, booleans use the text section (226 mismatches → 0 in 352 TEXT cells) |
|
||||
| 2026-10-11 | minor | ODDFPRICE, ODDFYIELD, ODDLPRICE, ODDLYIELD added: Financial 55 / 55; 505 / 523 by name, 496 working |
|
||||
| 2026-10-11 | minor | PERCENTOF and EUROCONVERT added: 503 / 523 by name, 494 working |
|
||||
| 2026-10-11 | minor | BAHTTEXT added: 502 / 523 by name, 493 working |
|
||||
|
|
|
|||
Loading…
Reference in New Issue