@dnax/ui 0.0.64 → 0.0.66
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/QDatePicker.vue +12 -5
- package/components/QSpreadsheet.vue +51 -6
- package/components/QTable.vue +20 -6
- package/components/QTabs.vue +11 -2
- package/components/internal/QDateCalendar.vue +25 -23
- package/index.ts +16 -0
- package/lib/dateI18n.test.ts +91 -0
- package/lib/dateI18n.ts +85 -0
- package/lib/formula.test.ts +186 -1
- package/lib/formula.ts +442 -0
- package/lib/network.test.ts +199 -0
- package/lib/network.ts +147 -0
- package/lib/q.ts +6 -1
- package/lib/styleTokens.test.ts +46 -0
- package/lib/wakeLock.test.ts +219 -0
- package/lib/wakeLock.ts +202 -0
- package/package.json +1 -1
- package/runtime.ts +7 -0
- package/styles/main.css +119 -23
package/lib/formula.test.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Tests unitaires du moteur de formules A1 (bun test).
|
|
2
2
|
import { describe, expect, it } from "bun:test"
|
|
3
|
-
import { evaluateFormula, isError } from "./formula"
|
|
3
|
+
import { criteriaMatcher, evaluateFormula, FORMULA_FUNCTION_NAMES, isError } from "./formula"
|
|
4
4
|
import type { FormulaValue } from "./formula"
|
|
5
5
|
|
|
6
6
|
type Grid = (string | number | boolean | null)[][]
|
|
@@ -204,3 +204,188 @@ describe("math & erreurs de type", () => {
|
|
|
204
204
|
expect(isError(e("=1/0"))).toBe(true)
|
|
205
205
|
})
|
|
206
206
|
})
|
|
207
|
+
|
|
208
|
+
// ─── Extension 2026-10-17 : critères, stats, recherche, dates, texte ───
|
|
209
|
+
|
|
210
|
+
describe("critères (SUMIF / COUNTIF / *IFS)", () => {
|
|
211
|
+
const grid: Grid = [
|
|
212
|
+
["A", "x", 10],
|
|
213
|
+
["B", "y", 20],
|
|
214
|
+
["C", "x", 30],
|
|
215
|
+
[null, null, null],
|
|
216
|
+
["D", "y", 40],
|
|
217
|
+
]
|
|
218
|
+
const e = harness(grid)
|
|
219
|
+
|
|
220
|
+
it("SUMIF : critère texte, comparaison numérique, plage de somme optionnelle", () => {
|
|
221
|
+
expect(e('=SUMIF(B1:B5,"x",C1:C5)')).toBe(40)
|
|
222
|
+
expect(e('=SUMIF(B1:B5,"y",C1:C5)')).toBe(60)
|
|
223
|
+
expect(e('=SUMIF(C1:C5,">15")')).toBe(90)
|
|
224
|
+
expect(e('=SUMIF(C1:C5,"<>")')).toBe(100)
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
it("COUNTIF : égalité (insensible à la casse), vides, jokers", () => {
|
|
228
|
+
expect(e('=COUNTIF(A1:A5,"a")')).toBe(1)
|
|
229
|
+
expect(e('=COUNTIF(A1:A5,"?")')).toBe(4)
|
|
230
|
+
expect(e('=COUNTIF(A1:A5,"")')).toBe(1)
|
|
231
|
+
expect(e('=COUNTIF(A1:A5,"<>")')).toBe(4)
|
|
232
|
+
expect(e('=COUNTIF(A1:A5,"a*")')).toBe(1)
|
|
233
|
+
expect(e('=COUNTIF(A1:A5,">b")')).toBe(2)
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
it("AVERAGEIF ignore les non-nombres ; #DIV/0! sans correspondance", () => {
|
|
237
|
+
expect(e('=AVERAGEIF(B1:B5,"y",C1:C5)')).toBe(30)
|
|
238
|
+
expect(String(e('=AVERAGEIF(B1:B5,"z",C1:C5)'))).toBe("#DIV/0!")
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
it("COUNTIFS / SUMIFS / AVERAGEIFS croisent plusieurs critères", () => {
|
|
242
|
+
expect(e('=COUNTIFS(B1:B5,"x",C1:C5,">15")')).toBe(1)
|
|
243
|
+
expect(e('=SUMIFS(C1:C5,B1:B5,"x",C1:C5,">15")')).toBe(30)
|
|
244
|
+
expect(e('=SUMIFS(C1:C5,B1:B5,"x")')).toBe(40)
|
|
245
|
+
expect(e('=AVERAGEIFS(C1:C5,B1:B5,"y")')).toBe(30)
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
it("SUMPRODUCT multiplie les plages terme à terme", () => {
|
|
249
|
+
expect(e("=SUMPRODUCT(C1:C3,C1:C3)")).toBe(1400)
|
|
250
|
+
expect(e("=SUMPRODUCT(C1:C2)")).toBe(30)
|
|
251
|
+
})
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
describe("criteriaMatcher (unitaire)", () => {
|
|
255
|
+
it("compare nombres, textes, vides et jokers", () => {
|
|
256
|
+
expect(criteriaMatcher(">10")(15)).toBe(true)
|
|
257
|
+
expect(criteriaMatcher(">10")("abc")).toBe(false)
|
|
258
|
+
expect(criteriaMatcher("<=5")(5)).toBe(true)
|
|
259
|
+
expect(criteriaMatcher(0)("0")).toBe(true)
|
|
260
|
+
expect(criteriaMatcher("paris")("PARIS")).toBe(true)
|
|
261
|
+
expect(criteriaMatcher("paris")("paris ")).toBe(false)
|
|
262
|
+
expect(criteriaMatcher("par*")("paris")).toBe(true)
|
|
263
|
+
expect(criteriaMatcher("<>")(null)).toBe(false)
|
|
264
|
+
expect(criteriaMatcher("")("")).toBe(true)
|
|
265
|
+
expect(criteriaMatcher("<m")("apple")).toBe(true)
|
|
266
|
+
})
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
describe("statistiques", () => {
|
|
270
|
+
const grid: Grid = [
|
|
271
|
+
[10],
|
|
272
|
+
[20],
|
|
273
|
+
[30],
|
|
274
|
+
[40],
|
|
275
|
+
]
|
|
276
|
+
const e = harness(grid)
|
|
277
|
+
|
|
278
|
+
it("MEDIAN / LARGE / SMALL / RANK", () => {
|
|
279
|
+
expect(e("=MEDIAN(A1:A4)")).toBe(25)
|
|
280
|
+
expect(e("=LARGE(A1:A4,2)")).toBe(30)
|
|
281
|
+
expect(e("=SMALL(A1:A4,2)")).toBe(20)
|
|
282
|
+
expect(e("=RANK(20,A1:A4)")).toBe(3)
|
|
283
|
+
expect(e("=RANK(20,A1:A4,1)")).toBe(2)
|
|
284
|
+
expect(String(e("=LARGE(A1:A4,9)"))).toBe("#NUM!")
|
|
285
|
+
expect(String(e("=RANK(99,A1:A4)"))).toBe("#N/A")
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
it("STDEV / STDEVP / VAR / VARP", () => {
|
|
289
|
+
expect(Number(e("=STDEV(A1:A4)"))).toBeCloseTo(12.9099, 3)
|
|
290
|
+
expect(Number(e("=STDEVP(A1:A4)"))).toBeCloseTo(11.1803, 3)
|
|
291
|
+
expect(Number(e("=VAR(A1:A4)"))).toBeCloseTo(166.6667, 3)
|
|
292
|
+
expect(e("=VARP(A1:A4)")).toBe(125)
|
|
293
|
+
expect(String(e("=STDEV(A1)"))).toBe("#DIV/0!")
|
|
294
|
+
})
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
describe("recherche INDEX / MATCH", () => {
|
|
298
|
+
const grid: Grid = [
|
|
299
|
+
["A", "x", 10],
|
|
300
|
+
["B", "y", 20],
|
|
301
|
+
["C", "x", 30],
|
|
302
|
+
]
|
|
303
|
+
const e = harness(grid)
|
|
304
|
+
|
|
305
|
+
it("INDEX sur une plage (ligne/colonne, index unique)", () => {
|
|
306
|
+
expect(e("=INDEX(C1:C3,2)")).toBe(20)
|
|
307
|
+
expect(e("=INDEX(A1:C1,2)")).toBe("x")
|
|
308
|
+
expect(e("=INDEX(A1:C3,3,3)")).toBe(30)
|
|
309
|
+
expect(String(e("=INDEX(A1:C3,9,1)"))).toBe("#REF!")
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
it("MATCH exact et approché", () => {
|
|
313
|
+
expect(e('=MATCH("x",B1:B3,0)')).toBe(1)
|
|
314
|
+
expect(e("=MATCH(30,C1:C3,0)")).toBe(3)
|
|
315
|
+
expect(e("=MATCH(25,C1:C3,1)")).toBe(2)
|
|
316
|
+
expect(String(e("=MATCH(99,C1:C3,0)"))).toBe("#N/A")
|
|
317
|
+
})
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
describe("littéraux TRUE / FALSE", () => {
|
|
321
|
+
const e = harness([[]])
|
|
322
|
+
it("valeurs booléennes en littéral et en fonction", () => {
|
|
323
|
+
expect(e("=IF(TRUE,1,2)")).toBe(1)
|
|
324
|
+
expect(e("=IF(FALSE,1,2)")).toBe(2)
|
|
325
|
+
expect(e("=TRUE()")).toBe(true)
|
|
326
|
+
expect(e("=FALSE()")).toBe(false)
|
|
327
|
+
expect(e('=IFS(0,1,TRUE,"x")')).toBe("x")
|
|
328
|
+
})
|
|
329
|
+
it("un identifiant plus long reste une référence", () => {
|
|
330
|
+
expect(String(e("=TRUE1"))).toBe("#NAME?")
|
|
331
|
+
})
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
describe("erreurs (ISERROR / ISERR / ISNA / NA)", () => {
|
|
335
|
+
const e = harness([[]])
|
|
336
|
+
it("distingue erreur générique, #N/A et absence d'erreur", () => {
|
|
337
|
+
expect(e("=ISERROR(1/0)")).toBe(true)
|
|
338
|
+
expect(e("=ISERROR(1)")).toBe(false)
|
|
339
|
+
expect(e("=ISERR(1/0)")).toBe(true)
|
|
340
|
+
expect(e("=ISERR(NA())")).toBe(false)
|
|
341
|
+
expect(e("=ISNA(NA())")).toBe(true)
|
|
342
|
+
expect(e("=ISNA(1/0)")).toBe(false)
|
|
343
|
+
expect(String(e("=NA()"))).toBe("#N/A")
|
|
344
|
+
})
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
describe("texte étendu", () => {
|
|
348
|
+
const e = harness([[]])
|
|
349
|
+
it("TEXTJOIN / PROPER / SEARCH / EXACT", () => {
|
|
350
|
+
expect(e('=TEXTJOIN("-",1,"a","","b")')).toBe("a-b")
|
|
351
|
+
expect(e('=TEXTJOIN("-",0,"a","","b")')).toBe("a--b")
|
|
352
|
+
expect(e('=PROPER("hello world")')).toBe("Hello World")
|
|
353
|
+
expect(e('=SEARCH("B","abc")')).toBe(2)
|
|
354
|
+
expect(String(e('=SEARCH("z","abc")'))).toBe("#VALUE!")
|
|
355
|
+
expect(e('=EXACT("a","A")')).toBe(false)
|
|
356
|
+
expect(e('=EXACT("a","a")')).toBe(true)
|
|
357
|
+
})
|
|
358
|
+
})
|
|
359
|
+
|
|
360
|
+
describe("dates étendues", () => {
|
|
361
|
+
const grid: Grid = [
|
|
362
|
+
["2026-10-05"],
|
|
363
|
+
["2026-10-06"],
|
|
364
|
+
]
|
|
365
|
+
const e = harness(grid)
|
|
366
|
+
it("DAYS / WEEKNUM / ISOWEEKNUM", () => {
|
|
367
|
+
expect(e('=DAYS("2026-10-10","2026-10-01")')).toBe(9)
|
|
368
|
+
expect(e('=WEEKNUM("2026-01-05",2)')).toBe(2)
|
|
369
|
+
expect(e('=ISOWEEKNUM("2026-01-05")')).toBe(2)
|
|
370
|
+
expect(e('=WEEKDAY("2026-10-05",2)')).toBe(1)
|
|
371
|
+
})
|
|
372
|
+
it("WORKDAY / NETWORKDAYS (avec et sans fériés)", () => {
|
|
373
|
+
expect(e('=WORKDAY("2026-10-16",1)')).toBe("2026-10-19")
|
|
374
|
+
expect(e('=NETWORKDAYS("2026-10-01","2026-10-07")')).toBe(5)
|
|
375
|
+
expect(e('=NETWORKDAYS("2026-10-01","2026-10-07",A1:A2)')).toBe(3)
|
|
376
|
+
})
|
|
377
|
+
})
|
|
378
|
+
|
|
379
|
+
describe("parité moteur ↔ autocomplétion", () => {
|
|
380
|
+
it("aucune fonction intégrée n'est absente de FORMULA_FNS", async () => {
|
|
381
|
+
const vue = await Bun.file(new URL("../components/QSpreadsheet.vue", import.meta.url)).text()
|
|
382
|
+
const start = vue.indexOf("const FORMULA_FNS")
|
|
383
|
+
const end = vue.indexOf("const fxSource")
|
|
384
|
+
const block = vue.slice(start, end)
|
|
385
|
+
const suggested = new Set([...block.matchAll(/name: "([A-Z]+)"/g)].map((m) => m[1]!))
|
|
386
|
+
// Alias internes volontairement absents des suggestions.
|
|
387
|
+
const aliases = new Set(["AVG", "CEIL", "POW"])
|
|
388
|
+
const missing = FORMULA_FUNCTION_NAMES.filter((n) => !aliases.has(n) && !suggested.has(n))
|
|
389
|
+
expect(missing).toEqual([])
|
|
390
|
+
})
|
|
391
|
+
})
|
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,6 +1236,119 @@ Object.assign(FUNCTIONS, {
|
|
|
907
1236
|
rand: () => Math.random(),
|
|
908
1237
|
})
|
|
909
1238
|
|
|
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
|
+
|
|
910
1352
|
/** Évalue une formule ("=…" optionnel). Retourne une valeur ou une FormulaError. */
|
|
911
1353
|
export function evaluateFormula(source: string, ctx: FormulaContext): FormulaValue {
|
|
912
1354
|
const src = source.startsWith("=") ? source.slice(1) : source
|