@dnax/ui 0.0.65 → 0.0.67
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/components/QSpreadsheet.vue +210 -26
- package/components/QTable.vue +20 -6
- package/lib/formula.test.ts +194 -1
- package/lib/formula.ts +446 -2
- package/lib/spreadsheet.test.ts +110 -0
- package/lib/spreadsheet.ts +204 -5
- package/lib/styleTokens.test.ts +15 -0
- package/package.json +1 -1
- package/styles/main.css +86 -7
package/lib/formula.ts
CHANGED
|
@@ -45,6 +45,7 @@ interface RefToken {
|
|
|
45
45
|
type Node =
|
|
46
46
|
| { kind: "num"; value: number }
|
|
47
47
|
| { kind: "str"; value: string }
|
|
48
|
+
| { kind: "bool"; value: boolean }
|
|
48
49
|
| { kind: "ref"; ref: RefToken }
|
|
49
50
|
| { kind: "range"; start: RefToken; end: RefToken }
|
|
50
51
|
| { kind: "call"; name: string; args: Node[] }
|
|
@@ -242,6 +243,13 @@ class Parser {
|
|
|
242
243
|
let digits = ""
|
|
243
244
|
while (!this.eof() && /[0-9]/.test(this.peek()!)) digits += this.next()
|
|
244
245
|
|
|
246
|
+
// Littéraux TRUE / FALSE (Excel) — ne s'appliquent pas à une référence type TRUE1.
|
|
247
|
+
const keyword = letters.toUpperCase()
|
|
248
|
+
if (!colAbs && !rowAbs && digits.length === 0 && (keyword === "TRUE" || keyword === "FALSE")) {
|
|
249
|
+
this.skipWs()
|
|
250
|
+
if (this.peek() !== ":") return { kind: "bool", value: keyword === "TRUE" }
|
|
251
|
+
}
|
|
252
|
+
|
|
245
253
|
let token: RefToken
|
|
246
254
|
if (digits.length > 0 && letters.length <= 3) {
|
|
247
255
|
token = {
|
|
@@ -327,6 +335,77 @@ const truthy = (v: FormulaValue): boolean => {
|
|
|
327
335
|
return v !== ""
|
|
328
336
|
}
|
|
329
337
|
|
|
338
|
+
/** Cellule « vide » au sens des critères (`null`, `undefined` ou chaîne vide). */
|
|
339
|
+
const isBlankValue = (v: FormulaValue): boolean => v === null || v === undefined || v === ""
|
|
340
|
+
|
|
341
|
+
/** Motif texte → expression régulière : `*` = tout, `?` = un caractère, insensible à la casse. */
|
|
342
|
+
const wildcardRegExp = (pattern: string): RegExp => {
|
|
343
|
+
let out = ""
|
|
344
|
+
for (const ch of pattern) {
|
|
345
|
+
if (ch === "*") out += ".*"
|
|
346
|
+
else if (ch === "?") out += "."
|
|
347
|
+
else out += ch.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
|
348
|
+
}
|
|
349
|
+
return new RegExp("^" + out + "$", "i")
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Transforme un critère de `SUMIF` / `COUNTIF` (…) en prédicat.
|
|
354
|
+
*
|
|
355
|
+
* - `10` / `"10"` → égalité numérique ;
|
|
356
|
+
* - `">10"`, `"<=5"`, `"<>abc"`, `"<m"` → comparaison (numérique ou texte) ;
|
|
357
|
+
* - `""` / `"<>"` → cellules **vides** / **non vides** ;
|
|
358
|
+
* - `*` et `?` → jokers sur le texte, insensible à la casse.
|
|
359
|
+
*/
|
|
360
|
+
export function criteriaMatcher(criteria: FormulaValue): (v: FormulaValue) => boolean {
|
|
361
|
+
if (isError(criteria)) return () => false
|
|
362
|
+
if (criteria === null || criteria === undefined) return isBlankValue
|
|
363
|
+
if (typeof criteria === "number" || typeof criteria === "boolean") {
|
|
364
|
+
const target = typeof criteria === "boolean" ? (criteria ? 1 : 0) : criteria
|
|
365
|
+
return (v) => {
|
|
366
|
+
if (isBlankValue(v)) return false
|
|
367
|
+
const n = typeof v === "number" ? v : Number(v)
|
|
368
|
+
return !Number.isNaN(n) && n === target
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const m = /^(>=|<=|<>|>|<|=)?([\s\S]*)$/.exec(String(criteria))!
|
|
373
|
+
const op = m[1] ?? "="
|
|
374
|
+
const raw = (m[2] ?? "").trim()
|
|
375
|
+
|
|
376
|
+
// Critères de vacuité : "" (vides) / "<>" (non vides)
|
|
377
|
+
if (raw === "" && (op === "=" || op === "<>")) {
|
|
378
|
+
return op === "=" ? isBlankValue : (v) => !isBlankValue(v)
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const numeric = raw !== "" && !Number.isNaN(Number(raw))
|
|
382
|
+
const equals = (v: FormulaValue): boolean => {
|
|
383
|
+
if (isBlankValue(v)) return false
|
|
384
|
+
if (numeric) {
|
|
385
|
+
const n = typeof v === "number" ? v : Number(v)
|
|
386
|
+
return !Number.isNaN(n) && n === Number(raw)
|
|
387
|
+
}
|
|
388
|
+
return wildcardRegExp(raw).test(toStr(v))
|
|
389
|
+
}
|
|
390
|
+
if (op === "=" || op === "<>") return op === "=" ? equals : (v) => !equals(v)
|
|
391
|
+
|
|
392
|
+
if (numeric) {
|
|
393
|
+
const target = Number(raw)
|
|
394
|
+
return (v) => {
|
|
395
|
+
if (isBlankValue(v)) return false
|
|
396
|
+
const n = typeof v === "number" ? v : Number(v)
|
|
397
|
+
if (Number.isNaN(n)) return false
|
|
398
|
+
return op === ">" ? n > target : op === "<" ? n < target : op === ">=" ? n >= target : n <= target
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
const target = raw.toLowerCase()
|
|
402
|
+
return (v) => {
|
|
403
|
+
if (isBlankValue(v)) return false
|
|
404
|
+
const s = toStr(v).toLowerCase()
|
|
405
|
+
return op === ">" ? s > target : op === "<" ? s < target : op === ">=" ? s >= target : s <= target
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
330
409
|
// collect=false ne produit jamais de tableau (les plages renvoient #VALUE!) —
|
|
331
410
|
// ce garde sert uniquement au typage.
|
|
332
411
|
const toScalar = (v: EvalValue): FormulaValue => (Array.isArray(v) ? err("#VALUE!") : v)
|
|
@@ -355,6 +434,8 @@ function evalNode(node: Node, ctx: FormulaContext, collect: boolean): EvalValue
|
|
|
355
434
|
return node.value
|
|
356
435
|
case "str":
|
|
357
436
|
return node.value
|
|
437
|
+
case "bool":
|
|
438
|
+
return node.value
|
|
358
439
|
case "ref":
|
|
359
440
|
return resolveRef(node.ref, ctx)
|
|
360
441
|
case "range": {
|
|
@@ -387,6 +468,18 @@ function evalNode(node: Node, ctx: FormulaContext, collect: boolean): EvalValue
|
|
|
387
468
|
}
|
|
388
469
|
}
|
|
389
470
|
|
|
471
|
+
// — Évaluation d'un argument en liste (plage → valeurs, scalaire → [valeur]) —
|
|
472
|
+
// Indispensable aux fonctions à plages multiples (`SUMIF`, `SUMPRODUCT`…) : la forme
|
|
473
|
+
// et l'ordre des arguments doivent rester distincts (contrairement au dispatch générique
|
|
474
|
+
// qui aplatit tous les arguments dans un seul tableau).
|
|
475
|
+
const evalList = (node: Node, ctx: FormulaContext): FormulaValue[] => {
|
|
476
|
+
const v = evalNode(node, ctx, true)
|
|
477
|
+
return Array.isArray(v) ? v : [v]
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
const evalScalar = (node: Node, ctx: FormulaContext): FormulaValue =>
|
|
481
|
+
toScalar(evalNode(node, ctx, false))
|
|
482
|
+
|
|
390
483
|
function evalBinary(node: Extract<Node, { kind: "binary" }>, ctx: FormulaContext): FormulaValue {
|
|
391
484
|
if (node.op === "&") {
|
|
392
485
|
const a = toScalar(evalNode(node.left, ctx, false))
|
|
@@ -605,6 +698,176 @@ function evalCall(node: Extract<Node, { kind: "call" }>, ctx: FormulaContext): F
|
|
|
605
698
|
return err("#VALUE!")
|
|
606
699
|
}
|
|
607
700
|
|
|
701
|
+
// ── Fonctions à plages multiples (alignées par index) ──
|
|
702
|
+
// La forme des arguments compte (plage de critères ↔ plage de valeurs) : elles sont
|
|
703
|
+
// donc évaluées ici, argument par argument, avant le dispatch générique qui aplatit.
|
|
704
|
+
const pairsFrom = (
|
|
705
|
+
from: number,
|
|
706
|
+
): [FormulaValue[], ((v: FormulaValue) => boolean)][] | FormulaError => {
|
|
707
|
+
const out: [FormulaValue[], ((v: FormulaValue) => boolean)][] = []
|
|
708
|
+
for (let i = from; i + 1 < node.args.length; i += 2) {
|
|
709
|
+
const criteria = evalScalar(node.args[i + 1]!, ctx)
|
|
710
|
+
if (isError(criteria)) return criteria
|
|
711
|
+
out.push([evalList(node.args[i]!, ctx), criteriaMatcher(criteria)])
|
|
712
|
+
}
|
|
713
|
+
return out
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/** Index (0-based) où **toutes** les paires (plage, critère) correspondent. */
|
|
717
|
+
const matched = (pairs: [FormulaValue[], ((v: FormulaValue) => boolean)][]): number[] => {
|
|
718
|
+
const len = pairs[0]?.[0].length ?? 0
|
|
719
|
+
const out: number[] = []
|
|
720
|
+
for (let i = 0; i < len; i++) {
|
|
721
|
+
if (pairs.every(([values, hit]) => hit(values[i] ?? null))) out.push(i)
|
|
722
|
+
}
|
|
723
|
+
return out
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
const numbersAt = (values: FormulaValue[], indexes: number[]): number[] =>
|
|
727
|
+
indexes.map((i) => values[i]).filter((v): v is number => typeof v === "number")
|
|
728
|
+
|
|
729
|
+
const holidaysOf = (n: Node | undefined): Set<string> => {
|
|
730
|
+
const out = new Set<string>()
|
|
731
|
+
if (!n) return out
|
|
732
|
+
for (const v of evalList(n, ctx)) {
|
|
733
|
+
const d = toLocalDate(v)
|
|
734
|
+
if (!isError(d)) out.add(dayKey(d))
|
|
735
|
+
}
|
|
736
|
+
return out
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
if ((name === "sumif" || name === "averageif") && node.args.length >= 2) {
|
|
740
|
+
const criteria = evalScalar(node.args[1]!, ctx)
|
|
741
|
+
if (isError(criteria)) return criteria
|
|
742
|
+
const criterionValues = evalList(node.args[0]!, ctx)
|
|
743
|
+
const valueValues = node.args[2] ? evalList(node.args[2], ctx) : criterionValues
|
|
744
|
+
const nums = numbersAt(valueValues, matched([[criterionValues, criteriaMatcher(criteria)]]))
|
|
745
|
+
const total = nums.reduce((a, b) => a + b, 0)
|
|
746
|
+
if (name === "sumif") return total
|
|
747
|
+
return nums.length ? total / nums.length : err("#DIV/0!")
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
if (name === "countif" && node.args.length >= 2) {
|
|
751
|
+
const criteria = evalScalar(node.args[1]!, ctx)
|
|
752
|
+
if (isError(criteria)) return criteria
|
|
753
|
+
return matched([[evalList(node.args[0]!, ctx), criteriaMatcher(criteria)]]).length
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
if (name === "countifs" && node.args.length >= 2) {
|
|
757
|
+
const pairs = pairsFrom(0)
|
|
758
|
+
if (isError(pairs)) return pairs
|
|
759
|
+
return matched(pairs).length
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
if ((name === "sumifs" || name === "averageifs") && node.args.length >= 3) {
|
|
763
|
+
const pairs = pairsFrom(1)
|
|
764
|
+
if (isError(pairs)) return pairs
|
|
765
|
+
const nums = numbersAt(evalList(node.args[0]!, ctx), matched(pairs))
|
|
766
|
+
const total = nums.reduce((a, b) => a + b, 0)
|
|
767
|
+
if (name === "sumifs") return total
|
|
768
|
+
return nums.length ? total / nums.length : err("#DIV/0!")
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
if (name === "sumproduct" && node.args.length >= 1) {
|
|
772
|
+
const lists = node.args.map((a) => evalList(a, ctx))
|
|
773
|
+
const len = lists[0]?.length ?? 0
|
|
774
|
+
if (!len || lists.some((l) => l.length !== len)) return err("#VALUE!")
|
|
775
|
+
let total = 0
|
|
776
|
+
for (let i = 0; i < len; i++) {
|
|
777
|
+
let product = 1
|
|
778
|
+
// Comme Excel : une valeur non numérique compte pour 0 dans le produit.
|
|
779
|
+
for (const l of lists) product *= typeof l[i] === "number" ? (l[i] as number) : 0
|
|
780
|
+
total += product
|
|
781
|
+
}
|
|
782
|
+
return total
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
if ((name === "large" || name === "small") && node.args.length >= 2) {
|
|
786
|
+
const values = evalList(node.args[0]!, ctx).filter((v): v is number => typeof v === "number")
|
|
787
|
+
const k = Math.trunc(Number(evalScalar(node.args[1]!, ctx)) || 0)
|
|
788
|
+
if (k < 1 || k > values.length) return err("#NUM!")
|
|
789
|
+
values.sort((a, b) => (name === "large" ? b - a : a - b))
|
|
790
|
+
return values[k - 1]!
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
if (name === "rank" && node.args.length >= 2) {
|
|
794
|
+
const target = evalScalar(node.args[0]!, ctx)
|
|
795
|
+
if (isError(target)) return target
|
|
796
|
+
const values = evalList(node.args[1]!, ctx).filter((v): v is number => typeof v === "number")
|
|
797
|
+
const n = Number(target)
|
|
798
|
+
if (Number.isNaN(n) || !values.includes(n)) return err("#N/A")
|
|
799
|
+
const order = node.args[2] ? Number(evalScalar(node.args[2], ctx)) : 0
|
|
800
|
+
const sorted = [...values].sort((a, b) => (order ? a - b : b - a))
|
|
801
|
+
return sorted.indexOf(n) + 1
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
if (name === "index" && node.args.length >= 2) {
|
|
805
|
+
const rangeNode = node.args[0]!
|
|
806
|
+
if (rangeNode.kind !== "range") return err("#VALUE!")
|
|
807
|
+
const a = resolveRefPos(rangeNode.start)
|
|
808
|
+
const b = resolveRefPos(rangeNode.end)
|
|
809
|
+
if (!a || !b) return err("#REF!")
|
|
810
|
+
const r0 = Math.min(a[0], b[0])
|
|
811
|
+
const r1 = Math.max(a[0], b[0])
|
|
812
|
+
const c0 = Math.min(a[1], b[1])
|
|
813
|
+
const c1 = Math.max(a[1], b[1])
|
|
814
|
+
const rows = r1 - r0 + 1
|
|
815
|
+
const cols = c1 - c0 + 1
|
|
816
|
+
const first = Math.trunc(Number(evalScalar(node.args[1]!, ctx)) || 0)
|
|
817
|
+
const second = node.args[2] ? Math.trunc(Number(evalScalar(node.args[2], ctx)) || 0) : 0
|
|
818
|
+
// Un seul index : ligne, ou colonne quand la plage n'a qu'une ligne (usage Excel).
|
|
819
|
+
const row = !second && rows === 1 ? 1 : first
|
|
820
|
+
const col = !second ? (rows === 1 ? first : 1) : second
|
|
821
|
+
if (row < 1 || col < 1 || row > rows || col > cols) return err("#REF!")
|
|
822
|
+
const value = ctx.resolveCell(r0 + row - 1, c0 + col - 1)
|
|
823
|
+
return value === undefined ? err("#REF!") : value
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
if (name === "match" && node.args.length >= 2) {
|
|
827
|
+
const rangeNode = node.args[1]!
|
|
828
|
+
if (rangeNode.kind !== "range") return err("#N/A")
|
|
829
|
+
const a = resolveRefPos(rangeNode.start)
|
|
830
|
+
const b = resolveRefPos(rangeNode.end)
|
|
831
|
+
if (!a || !b) return err("#N/A")
|
|
832
|
+
const key = evalScalar(node.args[0]!, ctx)
|
|
833
|
+
if (isError(key)) return key
|
|
834
|
+
const values: FormulaValue[] = []
|
|
835
|
+
for (let r = Math.min(a[0], b[0]); r <= Math.max(a[0], b[0]); r++)
|
|
836
|
+
for (let c = Math.min(a[1], b[1]); c <= Math.max(a[1], b[1]); c++)
|
|
837
|
+
values.push(ctx.resolveCell(r, c) ?? null)
|
|
838
|
+
const type = node.args[2] ? Math.trunc(Number(evalScalar(node.args[2], ctx)) || 0) : 1
|
|
839
|
+
if (type === 0) {
|
|
840
|
+
const at = values.findIndex((v) =>
|
|
841
|
+
typeof key === "number" && typeof v === "number"
|
|
842
|
+
? v === key
|
|
843
|
+
: toStr(v).toLowerCase() === toStr(key).toLowerCase())
|
|
844
|
+
return at === -1 ? err("#N/A") : at + 1
|
|
845
|
+
}
|
|
846
|
+
// Approché : dernier ≤ clé (croissant) / dernier ≥ clé (décroissant)
|
|
847
|
+
let best = -1
|
|
848
|
+
values.forEach((v, i) => {
|
|
849
|
+
const cmp = compare(v, key)
|
|
850
|
+
if (cmp === "err") return
|
|
851
|
+
if (type === -1 ? cmp >= 0 : cmp <= 0) best = i
|
|
852
|
+
})
|
|
853
|
+
return best === -1 ? err("#N/A") : best + 1
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
if (name === "workday" && node.args.length >= 2) {
|
|
857
|
+
const start = toLocalDate(evalScalar(node.args[0]!, ctx))
|
|
858
|
+
if (isError(start)) return start
|
|
859
|
+
const days = Math.trunc(Number(evalScalar(node.args[1]!, ctx)) || 0)
|
|
860
|
+
return isoLocal(addWorkdays(start, days, holidaysOf(node.args[2])))
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
if (name === "networkdays" && node.args.length >= 2) {
|
|
864
|
+
const start = toLocalDate(evalScalar(node.args[0]!, ctx))
|
|
865
|
+
const end = toLocalDate(evalScalar(node.args[1]!, ctx))
|
|
866
|
+
if (isError(start)) return start
|
|
867
|
+
if (isError(end)) return end
|
|
868
|
+
return countWorkdays(start, end, holidaysOf(node.args[2]))
|
|
869
|
+
}
|
|
870
|
+
|
|
608
871
|
const fn = FUNCTIONS[name]
|
|
609
872
|
if (!fn) return err("#NAME?")
|
|
610
873
|
|
|
@@ -733,6 +996,72 @@ const isoLocal = (d: Date, time = false) => {
|
|
|
733
996
|
const isoUTC = (d: Date) =>
|
|
734
997
|
d.getUTCFullYear() + "-" + pad2(d.getUTCMonth() + 1) + "-" + pad2(d.getUTCDate())
|
|
735
998
|
|
|
999
|
+
// — Dates « métier » (jours ouvrés, semaines) — calculs en UTC pour ignorer le fuseau/DST.
|
|
1000
|
+
/** Clé de jour (`YYYY-MM-DD`) insensible à l'heure et au fuseau. */
|
|
1001
|
+
const dayKey = (d: Date): string =>
|
|
1002
|
+
isoUTC(new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate())))
|
|
1003
|
+
|
|
1004
|
+
/** Numéro de semaine « type Excel » : `startDay` 0 = dimanche, 1 = lundi. */
|
|
1005
|
+
const weekNumber = (d: Date, startDay: number): number => {
|
|
1006
|
+
const jan1 = Date.UTC(d.getFullYear(), 0, 1)
|
|
1007
|
+
const today = Date.UTC(d.getFullYear(), d.getMonth(), d.getDate())
|
|
1008
|
+
const offset = (new Date(jan1).getUTCDay() - startDay + 7) % 7
|
|
1009
|
+
const dayOfYear = Math.round((today - jan1) / 86400000)
|
|
1010
|
+
return Math.floor((dayOfYear + offset) / 7) + 1
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
/** Numéro de semaine ISO 8601 (semaine 1 = celle du premier jeudi). */
|
|
1014
|
+
const isoWeekNumber = (d: Date): number => {
|
|
1015
|
+
const t = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()))
|
|
1016
|
+
const day = t.getUTCDay() || 7
|
|
1017
|
+
t.setUTCDate(t.getUTCDate() + 4 - day)
|
|
1018
|
+
const yearStart = Date.UTC(t.getUTCFullYear(), 0, 1)
|
|
1019
|
+
return Math.ceil(((t.getTime() - yearStart) / 86400000 + 1) / 7)
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
/** Ajoute (ou retire) des jours ouvrés (lun–ven), hors jours fériés. */
|
|
1023
|
+
const addWorkdays = (start: Date, days: number, holidays: Set<string>): Date => {
|
|
1024
|
+
let t = Date.UTC(start.getFullYear(), start.getMonth(), start.getDate())
|
|
1025
|
+
const step = days < 0 ? -86400000 : 86400000
|
|
1026
|
+
let left = Math.abs(Math.trunc(days))
|
|
1027
|
+
while (left > 0) {
|
|
1028
|
+
t += step
|
|
1029
|
+
const d = new Date(t)
|
|
1030
|
+
const wd = d.getUTCDay()
|
|
1031
|
+
if (wd === 0 || wd === 6) continue
|
|
1032
|
+
if (holidays.has(isoUTC(d))) continue
|
|
1033
|
+
left--
|
|
1034
|
+
}
|
|
1035
|
+
const out = new Date(t)
|
|
1036
|
+
return new Date(out.getUTCFullYear(), out.getUTCMonth(), out.getUTCDate())
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
/** Jours ouvrés entre deux dates (bornes incluses ; négatif si `end` < `start`). */
|
|
1040
|
+
const countWorkdays = (start: Date, end: Date, holidays: Set<string>): number => {
|
|
1041
|
+
const a = Date.UTC(start.getFullYear(), start.getMonth(), start.getDate())
|
|
1042
|
+
const b = Date.UTC(end.getFullYear(), end.getMonth(), end.getDate())
|
|
1043
|
+
const sign = a <= b ? 1 : -1
|
|
1044
|
+
const from = Math.min(a, b)
|
|
1045
|
+
const to = Math.max(a, b)
|
|
1046
|
+
let n = 0
|
|
1047
|
+
for (let t = from; t <= to; t += 86400000) {
|
|
1048
|
+
const d = new Date(t)
|
|
1049
|
+
const wd = d.getUTCDay()
|
|
1050
|
+
if (wd === 0 || wd === 6) continue
|
|
1051
|
+
if (holidays.has(isoUTC(d))) continue
|
|
1052
|
+
n++
|
|
1053
|
+
}
|
|
1054
|
+
return n * sign
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
/** Variance d'une liste de nombres (`sample` → n−1, sinon population). */
|
|
1058
|
+
const varianceOf = (values: number[], sample: boolean): FormulaValue => {
|
|
1059
|
+
if (values.length < (sample ? 2 : 1)) return err("#DIV/0!")
|
|
1060
|
+
const mean = values.reduce((a, b) => a + b, 0) / values.length
|
|
1061
|
+
const sum = values.reduce((a, b) => a + (b - mean) ** 2, 0)
|
|
1062
|
+
return sum / (sample ? values.length - 1 : values.length)
|
|
1063
|
+
}
|
|
1064
|
+
|
|
736
1065
|
Object.assign(FUNCTIONS, {
|
|
737
1066
|
// Logique étendue (évaluées paresseusement dans evalCall : IFS, SWITCH, IFNA, VLOOKUP)
|
|
738
1067
|
// Texte
|
|
@@ -907,9 +1236,124 @@ Object.assign(FUNCTIONS, {
|
|
|
907
1236
|
rand: () => Math.random(),
|
|
908
1237
|
})
|
|
909
1238
|
|
|
910
|
-
|
|
1239
|
+
// ─── Critères, statistiques, texte et dates (extension 2026-10-17) ───
|
|
1240
|
+
// Les agrégats conditionnels (`SUMIF`, `COUNTIF`, `*IFS`, `SUMPRODUCT`), `INDEX`/`MATCH`,
|
|
1241
|
+
// `LARGE`/`SMALL`/`RANK` et `WORKDAY`/`NETWORKDAYS` sont traités dans `evalCall` (plages).
|
|
1242
|
+
Object.assign(FUNCTIONS, {
|
|
1243
|
+
median: (args: FormulaValue[]) => {
|
|
1244
|
+
const n = numbers(args).sort((a, b) => a - b)
|
|
1245
|
+
if (!n.length) return err("#NUM!")
|
|
1246
|
+
const mid = Math.floor(n.length / 2)
|
|
1247
|
+
return n.length % 2 ? n[mid]! : (n[mid - 1]! + n[mid]!) / 2
|
|
1248
|
+
},
|
|
1249
|
+
stdev: (args: FormulaValue[]) => {
|
|
1250
|
+
const v = varianceOf(numbers(args), true)
|
|
1251
|
+
return typeof v === "number" ? Math.sqrt(v) : v
|
|
1252
|
+
},
|
|
1253
|
+
stdevp: (args: FormulaValue[]) => {
|
|
1254
|
+
const v = varianceOf(numbers(args), false)
|
|
1255
|
+
return typeof v === "number" ? Math.sqrt(v) : v
|
|
1256
|
+
},
|
|
1257
|
+
var: (args: FormulaValue[]) => varianceOf(numbers(args), true),
|
|
1258
|
+
varp: (args: FormulaValue[]) => varianceOf(numbers(args), false),
|
|
1259
|
+
// Constantes logiques (aussi disponibles en littéraux `TRUE` / `FALSE`)
|
|
1260
|
+
true: () => true,
|
|
1261
|
+
false: () => false,
|
|
1262
|
+
// Info / erreurs
|
|
1263
|
+
iserror: (args: FormulaValue[], h: boolean) => scalar(args, h, (v) => isError(v)),
|
|
1264
|
+
iserr: (args: FormulaValue[], h: boolean) =>
|
|
1265
|
+
scalar(args, h, (v) => isError(v) && v.code !== "#N/A"),
|
|
1266
|
+
isna: (args: FormulaValue[], h: boolean) =>
|
|
1267
|
+
scalar(args, h, (v) => isError(v) && v.code === "#N/A"),
|
|
1268
|
+
na: () => err("#N/A"),
|
|
1269
|
+
// Texte
|
|
1270
|
+
proper: (args: FormulaValue[], h: boolean) =>
|
|
1271
|
+
scalar(args, h, (v) =>
|
|
1272
|
+
toStr(v)
|
|
1273
|
+
.toLowerCase()
|
|
1274
|
+
.replace(/(^|[^\p{L}\p{N}])(\p{L})/gu, (_m: string, sep: string, ch: string) => sep + ch.toUpperCase())),
|
|
1275
|
+
exact: (args: FormulaValue[]) => toStr(args[0] ?? null) === toStr(args[1] ?? null),
|
|
1276
|
+
search: (args: FormulaValue[]) => {
|
|
1277
|
+
const needle = toStr(args[0] ?? null).toLowerCase()
|
|
1278
|
+
const hay = toStr(args[1] ?? null).toLowerCase()
|
|
1279
|
+
const start = Math.max(1, Math.trunc(Number(args[2] ?? 1)) || 1) - 1
|
|
1280
|
+
const at = hay.indexOf(needle, start)
|
|
1281
|
+
return at === -1 ? err("#VALUE!") : at + 1
|
|
1282
|
+
},
|
|
1283
|
+
textjoin: (args: FormulaValue[]) => {
|
|
1284
|
+
const delimiter = toStr(args[0] ?? "")
|
|
1285
|
+
const ignoreEmpty = args[1] === undefined ? true : truthy(args[1])
|
|
1286
|
+
return args
|
|
1287
|
+
.slice(2)
|
|
1288
|
+
.filter((v) => !(ignoreEmpty && (v === null || v === undefined || v === "")))
|
|
1289
|
+
.map(toStr)
|
|
1290
|
+
.join(delimiter)
|
|
1291
|
+
},
|
|
1292
|
+
// Dates
|
|
1293
|
+
days: (args: FormulaValue[]) => {
|
|
1294
|
+
const end = toLocalDate(args[0] ?? null)
|
|
1295
|
+
const start = toLocalDate(args[1] ?? null)
|
|
1296
|
+
if (isError(end)) return end
|
|
1297
|
+
if (isError(start)) return start
|
|
1298
|
+
const a = Date.UTC(end.getFullYear(), end.getMonth(), end.getDate())
|
|
1299
|
+
const b = Date.UTC(start.getFullYear(), start.getMonth(), start.getDate())
|
|
1300
|
+
return Math.round((a - b) / 86400000)
|
|
1301
|
+
},
|
|
1302
|
+
weeknum: (args: FormulaValue[]) => {
|
|
1303
|
+
const d = toLocalDate(args[0] ?? null)
|
|
1304
|
+
if (isError(d)) return d
|
|
1305
|
+
const type = Math.trunc(Number(args[1] ?? 1)) || 1
|
|
1306
|
+
return weekNumber(d, type === 2 ? 1 : 0)
|
|
1307
|
+
},
|
|
1308
|
+
isoweeknum: (args: FormulaValue[]) => {
|
|
1309
|
+
const d = toLocalDate(args[0] ?? null)
|
|
1310
|
+
return isError(d) ? d : isoWeekNumber(d)
|
|
1311
|
+
},
|
|
1312
|
+
})
|
|
1313
|
+
|
|
1314
|
+
/** Fonctions évaluées hors `FUNCTIONS` (accès aux plages / arguments paresseux). */
|
|
1315
|
+
const NODE_FUNCTION_NAMES = [
|
|
1316
|
+
"AND",
|
|
1317
|
+
"AVERAGEIF",
|
|
1318
|
+
"AVERAGEIFS",
|
|
1319
|
+
"COUNTIF",
|
|
1320
|
+
"COUNTIFS",
|
|
1321
|
+
"IF",
|
|
1322
|
+
"IFERROR",
|
|
1323
|
+
"IFNA",
|
|
1324
|
+
"IFS",
|
|
1325
|
+
"INDEX",
|
|
1326
|
+
"LARGE",
|
|
1327
|
+
"MATCH",
|
|
1328
|
+
"NETWORKDAYS",
|
|
1329
|
+
"OR",
|
|
1330
|
+
"RANK",
|
|
1331
|
+
"SMALL",
|
|
1332
|
+
"SUMPRODUCT",
|
|
1333
|
+
"SUMIF",
|
|
1334
|
+
"SUMIFS",
|
|
1335
|
+
"SWITCH",
|
|
1336
|
+
"VLOOKUP",
|
|
1337
|
+
"WORKDAY",
|
|
1338
|
+
"XLOOKUP",
|
|
1339
|
+
]
|
|
1340
|
+
|
|
1341
|
+
/**
|
|
1342
|
+
* Noms des fonctions intégrées (majuscules, triés) — contrat avec les suggestions de
|
|
1343
|
+
* l'éditeur : aucune fonction ne doit être « invisible » dans l'autocomplétion.
|
|
1344
|
+
*/
|
|
1345
|
+
export const FORMULA_FUNCTION_NAMES: string[] = [
|
|
1346
|
+
...new Set([
|
|
1347
|
+
...Object.keys(FUNCTIONS).map((name) => name.toUpperCase()),
|
|
1348
|
+
...NODE_FUNCTION_NAMES,
|
|
1349
|
+
]),
|
|
1350
|
+
].sort()
|
|
1351
|
+
|
|
1352
|
+
/** Évalue une formule ("=…" optionnel, espaces ignorés). Retourne une valeur ou une FormulaError. */
|
|
911
1353
|
export function evaluateFormula(source: string, ctx: FormulaContext): FormulaValue {
|
|
912
|
-
|
|
1354
|
+
// `isFormulaRaw` accepte des espaces avant le `=` : le moteur doit en faire autant.
|
|
1355
|
+
const trimmed = source.trim()
|
|
1356
|
+
const src = trimmed.startsWith("=") ? trimmed.slice(1) : trimmed
|
|
913
1357
|
try {
|
|
914
1358
|
const parser = new Parser(src)
|
|
915
1359
|
const node = parser.parseExpr()
|
package/lib/spreadsheet.test.ts
CHANGED
|
@@ -6,6 +6,10 @@ import {
|
|
|
6
6
|
isBlankValue,
|
|
7
7
|
isoAddDays,
|
|
8
8
|
shiftFormulaRefs,
|
|
9
|
+
functionQueryAt,
|
|
10
|
+
rankSuggestions,
|
|
11
|
+
functionCallAt,
|
|
12
|
+
signatureArgs,
|
|
9
13
|
csvEscape,
|
|
10
14
|
csvSplitLine,
|
|
11
15
|
parseCsv,
|
|
@@ -44,6 +48,112 @@ describe("shiftFormulaRefs (autofill)", () => {
|
|
|
44
48
|
it("laisse les valeurs sans lettres intactes", () => {
|
|
45
49
|
expect(shiftFormulaRefs("=1+2", 1, 1)).toBe("=1+2")
|
|
46
50
|
})
|
|
51
|
+
it("ne touche pas aux littéraux de chaîne", () => {
|
|
52
|
+
expect(shiftFormulaRefs('=IF(A1>0,"A1","x")', 1, 0)).toBe('=IF(A2>0,"A1","x")')
|
|
53
|
+
expect(shiftFormulaRefs("=CONCAT('B2',A1)", 0, 1)).toBe("=CONCAT('B2',B1)")
|
|
54
|
+
})
|
|
55
|
+
it("ne confond pas un identifiant avec une référence", () => {
|
|
56
|
+
expect(shiftFormulaRefs("=LOG10(A1)", 1, 0)).toBe("=LOG10(A2)")
|
|
57
|
+
expect(shiftFormulaRefs("=price1+A1", 1, 0)).toBe("=price1+A2")
|
|
58
|
+
expect(shiftFormulaRefs("=A1:price2", 1, 0)).toBe("=A2:price2")
|
|
59
|
+
})
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
describe("functionQueryAt (autocomplétion fx)", () => {
|
|
63
|
+
it("rend l'identifiant en cours de frappe, en minuscules", () => {
|
|
64
|
+
expect(functionQueryAt("=SU")).toBe("su")
|
|
65
|
+
expect(functionQueryAt("=sum(")).toBe("")
|
|
66
|
+
expect(functionQueryAt("=SUM(A1)+IF")).toBe("if")
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it("ne suggère rien hors d'une formule ou dans une chaîne", () => {
|
|
70
|
+
expect(functionQueryAt("SUM")).toBe("")
|
|
71
|
+
expect(functionQueryAt('="hello wor')).toBe("")
|
|
72
|
+
expect(functionQueryAt("='hello wor")).toBe("")
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it("ne suggère pas au milieu d'une référence ou d'une plage", () => {
|
|
76
|
+
expect(functionQueryAt("=A1", 2)).toBe("")
|
|
77
|
+
expect(functionQueryAt("=A1:B")).toBe("")
|
|
78
|
+
expect(functionQueryAt("=$AB")).toBe("")
|
|
79
|
+
expect(functionQueryAt("=A1+B")).toBe("b")
|
|
80
|
+
expect(functionQueryAt("=SUM(A")).toBe("a")
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it("suit le curseur, pas seulement la fin du texte", () => {
|
|
84
|
+
expect(functionQueryAt("=SU+1", 3)).toBe("su")
|
|
85
|
+
expect(functionQueryAt("=SU+1", 5)).toBe("")
|
|
86
|
+
})
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
describe("rankSuggestions (autocomplétion)", () => {
|
|
90
|
+
const items = [
|
|
91
|
+
{ name: "SUM" },
|
|
92
|
+
{ name: "SUMIF" },
|
|
93
|
+
{ name: "SUMPRODUCT" },
|
|
94
|
+
{ name: "MID" },
|
|
95
|
+
{ name: "MEDIAN" },
|
|
96
|
+
{ name: "IF" },
|
|
97
|
+
{ name: "IFS" },
|
|
98
|
+
]
|
|
99
|
+
|
|
100
|
+
it("classe égalité > préfixe, puis nom le plus court", () => {
|
|
101
|
+
expect(rankSuggestions(items, "sum").map((i) => i.name)).toEqual([
|
|
102
|
+
"SUM",
|
|
103
|
+
"SUMIF",
|
|
104
|
+
"SUMPRODUCT",
|
|
105
|
+
])
|
|
106
|
+
expect(rankSuggestions(items, "if").map((i) => i.name)).toEqual(["IF", "IFS", "SUMIF"])
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it("trouve une sous-chaîne", () => {
|
|
110
|
+
expect(rankSuggestions(items, "edian").map((i) => i.name)).toEqual(["MEDIAN"])
|
|
111
|
+
expect(rankSuggestions(items, "ia").map((i) => i.name)).toEqual(["MEDIAN"])
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it("trouve une sous-séquence (fuzzy)", () => {
|
|
115
|
+
expect(rankSuggestions(items, "sm").map((i) => i.name)).toEqual([
|
|
116
|
+
"SUM",
|
|
117
|
+
"SUMIF",
|
|
118
|
+
"SUMPRODUCT",
|
|
119
|
+
])
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it("respecte la limite et ignore une requête vide", () => {
|
|
123
|
+
expect(rankSuggestions(items, "s", 1)).toHaveLength(1)
|
|
124
|
+
expect(rankSuggestions(items, "")).toEqual([])
|
|
125
|
+
expect(rankSuggestions(items, "zzz")).toEqual([])
|
|
126
|
+
})
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
describe("functionCallAt / signatureArgs (aide à l'argument)", () => {
|
|
130
|
+
const known = ["SUM", "IF", "MAX"]
|
|
131
|
+
|
|
132
|
+
it("détecte la fonction et l'index de l'argument courant", () => {
|
|
133
|
+
expect(functionCallAt("=SUM(", 5, known)).toEqual({ name: "sum", index: 0 })
|
|
134
|
+
expect(functionCallAt("=SUM(A1,", 8, known)).toEqual({ name: "sum", index: 1 })
|
|
135
|
+
expect(functionCallAt("=IF(A1>0,", 9, known)).toEqual({ name: "if", index: 1 })
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it("prend la fonction la plus interne", () => {
|
|
139
|
+
expect(functionCallAt("=SUM(IF(A1>0,", 13, known)).toEqual({ name: "if", index: 1 })
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it("ignore les virgules des chaînes et les appels fermés", () => {
|
|
143
|
+
expect(functionCallAt('=IF(A1,"x,y",', 7, known)).toEqual({ name: "if", index: 1 })
|
|
144
|
+
expect(functionCallAt('=IF(A1,"x,y",', 13, known)).toEqual({ name: "if", index: 2 })
|
|
145
|
+
expect(functionCallAt("=A1+B1", 6, known)).toBeNull()
|
|
146
|
+
expect(functionCallAt("=SUM(A1)", 9, known)).toBeNull()
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it("signatureArgs découpe les paramètres", () => {
|
|
150
|
+
expect(signatureArgs("range, criteria, [sum_range]")).toEqual([
|
|
151
|
+
"range",
|
|
152
|
+
"criteria",
|
|
153
|
+
"[sum_range]",
|
|
154
|
+
])
|
|
155
|
+
expect(signatureArgs("")).toEqual([])
|
|
156
|
+
})
|
|
47
157
|
})
|
|
48
158
|
|
|
49
159
|
describe("dates ISO", () => {
|