Coalesce quota refreshes and consolidate shared helpers
Some checks failed
Deploy Sokko G / deploy (push) Failing after 2s
Some checks failed
Deploy Sokko G / deploy (push) Failing after 2s
This commit is contained in:
parent
a4cf633ab4
commit
748415082b
19 changed files with 216 additions and 64 deletions
|
|
@ -24,3 +24,5 @@
|
||||||
{"id":"int-7ec422ecc24735e12910a965567f32fe","kind":"field_change","created_at":"2026-09-10T12:17:38.573656548Z","actor":"Shinuwa","issue_id":"sokko-g-531","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Concurrent image batches merge current data after compression; calendar selection handlers update drafts outside state updaters. Behavioral image test and reverse week/month selection E2E pass; npm run check passes."}}
|
{"id":"int-7ec422ecc24735e12910a965567f32fe","kind":"field_change","created_at":"2026-09-10T12:17:38.573656548Z","actor":"Shinuwa","issue_id":"sokko-g-531","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Concurrent image batches merge current data after compression; calendar selection handlers update drafts outside state updaters. Behavioral image test and reverse week/month selection E2E pass; npm run check passes."}}
|
||||||
{"id":"int-1668982a1dda5d8304440ae699a32d40","kind":"field_change","created_at":"2026-09-10T12:22:11.969660039Z","actor":"Shinuwa","issue_id":"sokko-g-5u5","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Recursive cache enabled for completed evaluations. Before: 131054 reads for 16 cells; after: 50 reads for 50 cells. Regression test failed before fix and passes after; npm run check and table persistence E2E pass."}}
|
{"id":"int-1668982a1dda5d8304440ae699a32d40","kind":"field_change","created_at":"2026-09-10T12:22:11.969660039Z","actor":"Shinuwa","issue_id":"sokko-g-5u5","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Recursive cache enabled for completed evaluations. Before: 131054 reads for 16 cells; after: 50 reads for 50 cells. Regression test failed before fix and passes after; npm run check and table persistence E2E pass."}}
|
||||||
{"id":"int-903a25fde459880bbbadc2764da920a2","kind":"field_change","created_at":"2026-09-10T16:58:56.754421196Z","actor":"Shinuwa","issue_id":"sokko-g-hf6","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Unified confirmed-state queue for all writes; imports use current-state updaters; atomic creation/link/deletion and synchronous transaction abort. Removed obsolete checkpoints. Deterministic concurrency/failure tests and real IndexedDB abort E2E pass; full suite 15/15 and npm run check pass."}}
|
{"id":"int-903a25fde459880bbbadc2764da920a2","kind":"field_change","created_at":"2026-09-10T16:58:56.754421196Z","actor":"Shinuwa","issue_id":"sokko-g-hf6","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Unified confirmed-state queue for all writes; imports use current-state updaters; atomic creation/link/deletion and synchronous transaction abort. Removed obsolete checkpoints. Deterministic concurrency/failure tests and real IndexedDB abort E2E pass; full suite 15/15 and npm run check pass."}}
|
||||||
|
{"id":"int-16ba759581961e395e133df1c6019895","kind":"field_change","created_at":"2026-09-10T17:21:13.173981957Z","actor":"Shinuwa","issue_id":"sokko-g-ra9","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Implemented quota coalescing and shared helpers. npm run check and all 15 E2E tests with --workers=2 pass; default concurrency timeouts tracked separately."}}
|
||||||
|
{"id":"int-df984435537f78d51a7a309feda1109c","kind":"field_change","created_at":"2026-09-10T17:21:18.79044889Z","actor":"Shinuwa","issue_id":"sokko-g-ckq","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Implemented quota coalescing and shared helpers. npm run check and all 15 E2E tests with --workers=2 pass; default concurrency timeouts tracked separately."}}
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,9 @@ Le build est généré dans `website/dist`.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
Le serveur lit automatiquement un fichier `.env` à la racine du projet.
|
Le serveur et Vite lisent automatiquement un fichier `.env` facultatif à la racine
|
||||||
|
du projet, avec le parseur natif de Node. Les variables déjà définies dans le
|
||||||
|
processus sont prioritaires, y compris lorsqu'elles sont vides.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,12 @@ sur sa clé et ne réécrivent pas les images des autres outils.
|
||||||
Cette coordination est locale à l'instance React ; elle ne synchronise pas
|
Cette coordination est locale à l'instance React ; elle ne synchronise pas
|
||||||
l'édition entre plusieurs onglets.
|
l'édition entre plusieurs onglets.
|
||||||
|
|
||||||
|
Les estimations de quota demandées après sauvegarde sont regroupées sur une
|
||||||
|
fenêtre de 250 ms, avec une seule estimation active. Une écriture intervenant
|
||||||
|
pendant le calcul déclenche une nouvelle estimation. Les sauvegardes ne sont
|
||||||
|
pas retardées ; en cas d'estimation indisponible, le dernier quota connu est
|
||||||
|
conservé.
|
||||||
|
|
||||||
Les toolboxes sont stockées dans `kv/toolboxes`.
|
Les toolboxes sont stockées dans `kv/toolboxes`.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
|
|
|
||||||
10
scripts/load-project-env.mjs
Normal file
10
scripts/load-project-env.mjs
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
// Rôle : charge le fichier .env facultatif avec le parseur natif de Node.
|
||||||
|
import { loadEnvFile } from "node:process";
|
||||||
|
|
||||||
|
export function loadProjectEnv(file = ".env") {
|
||||||
|
try {
|
||||||
|
loadEnvFile(file);
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code !== "ENOENT") throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
22
server.mjs
22
server.mjs
|
|
@ -1,5 +1,6 @@
|
||||||
// Rôle : sert l'application Vite construite ou les sources publiques en local.
|
// Rôle : sert l'application Vite construite ou les sources publiques en local.
|
||||||
import { createReadStream, existsSync, readFileSync, statSync } from "node:fs";
|
import { createReadStream, existsSync, statSync } from "node:fs";
|
||||||
|
import { loadProjectEnv } from "./scripts/load-project-env.mjs";
|
||||||
import { createServer } from "node:http";
|
import { createServer } from "node:http";
|
||||||
import { extname, join, relative, resolve } from "node:path";
|
import { extname, join, relative, resolve } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
@ -10,24 +11,7 @@ const distRoot = join(websiteRoot, "dist");
|
||||||
const root = existsSync(distRoot) ? distRoot : websiteRoot;
|
const root = existsSync(distRoot) ? distRoot : websiteRoot;
|
||||||
const publicRoot = existsSync(distRoot) ? distRoot : join(websiteRoot, "public");
|
const publicRoot = existsSync(distRoot) ? distRoot : join(websiteRoot, "public");
|
||||||
|
|
||||||
function loadEnvFile(file = ".env") {
|
loadProjectEnv();
|
||||||
if (!existsSync(file)) return;
|
|
||||||
|
|
||||||
const lines = readFileSync(file, "utf8").split(/\r?\n/);
|
|
||||||
lines.forEach((line) => {
|
|
||||||
const trimmed = line.trim();
|
|
||||||
if (!trimmed || trimmed.startsWith("#")) return;
|
|
||||||
|
|
||||||
const separatorIndex = trimmed.indexOf("=");
|
|
||||||
if (separatorIndex < 1) return;
|
|
||||||
|
|
||||||
const key = trimmed.slice(0, separatorIndex).trim();
|
|
||||||
const value = trimmed.slice(separatorIndex + 1).trim().replace(/^["']|["']$/g, "");
|
|
||||||
if (!process.env[key]) process.env[key] = value;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
loadEnvFile();
|
|
||||||
|
|
||||||
const port = Number(process.env.PORT || 5173);
|
const port = Number(process.env.PORT || 5173);
|
||||||
|
|
||||||
|
|
|
||||||
4
tests/fixtures/project-env.txt
vendored
Normal file
4
tests/fixtures/project-env.txt
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
# Rôle : fournit des variables non sensibles pour tester le chargement .env.
|
||||||
|
SOKKOG_ENV_FIXTURE_PORT=4321
|
||||||
|
SOKKOG_ENV_FIXTURE_QUOTED="value # inside"
|
||||||
|
SOKKOG_ENV_FIXTURE_COMMENT=value # outside
|
||||||
20
tests/project-env.test.mjs
Normal file
20
tests/project-env.test.mjs
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
// Rôle : vérifie le parsing .env, la priorité du processus et les fichiers facultatifs.
|
||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { loadProjectEnv } from "../scripts/load-project-env.mjs";
|
||||||
|
|
||||||
|
for (const existing of ["9999", ""]) {
|
||||||
|
test(`project env preserves an existing ${existing ? "nonempty" : "empty"} value`, (t) => {
|
||||||
|
const keys = ["SOKKOG_ENV_FIXTURE_PORT", "SOKKOG_ENV_FIXTURE_QUOTED", "SOKKOG_ENV_FIXTURE_COMMENT"];
|
||||||
|
const previous = keys.map((key) => process.env[key]);
|
||||||
|
t.after(() => keys.forEach((key, index) => {
|
||||||
|
if (previous[index] === undefined) delete process.env[key];
|
||||||
|
else process.env[key] = previous[index];
|
||||||
|
}));
|
||||||
|
keys.forEach((key) => { delete process.env[key]; });
|
||||||
|
process.env.SOKKOG_ENV_FIXTURE_PORT = existing;
|
||||||
|
loadProjectEnv("tests/fixtures/project-env.txt");
|
||||||
|
loadProjectEnv("tests/fixtures/missing-env-file");
|
||||||
|
assert.deepEqual(keys.map((key) => process.env[key]), [existing, "value # inside", "value"]);
|
||||||
|
});
|
||||||
|
}
|
||||||
90
tests/quota-refresh.test.mjs
Normal file
90
tests/quota-refresh.test.mjs
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
// Rôle : teste les rafales d'estimations, la concurrence et le nettoyage du quota.
|
||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { createQuotaRefresh } from "../website/src/features/toolboxes/storage/quotaRefresh.js";
|
||||||
|
|
||||||
|
function setup(estimate) {
|
||||||
|
const timers = new Map();
|
||||||
|
const results = [];
|
||||||
|
let id = 0;
|
||||||
|
const refresh = createQuotaRefresh({
|
||||||
|
estimate,
|
||||||
|
onResult: (result) => results.push(result),
|
||||||
|
schedule: (callback) => { timers.set(++id, callback); return id; },
|
||||||
|
cancel: (key) => timers.delete(key)
|
||||||
|
});
|
||||||
|
function tick() {
|
||||||
|
assert.equal(timers.size, 1);
|
||||||
|
const [key, callback] = timers.entries().next().value;
|
||||||
|
timers.delete(key);
|
||||||
|
return callback();
|
||||||
|
}
|
||||||
|
return { ...refresh, timers, results, tick };
|
||||||
|
}
|
||||||
|
|
||||||
|
test("one hundred refresh requests trigger one estimate", async () => {
|
||||||
|
let calls = 0;
|
||||||
|
const refresh = setup(async () => { calls++; return { usage: 12 }; });
|
||||||
|
for (let i = 0; i < 100; i++) refresh.request();
|
||||||
|
await refresh.tick();
|
||||||
|
assert.equal(calls, 1);
|
||||||
|
assert.deepEqual(refresh.results, [{ usage: 12 }]);
|
||||||
|
assert.equal(refresh.timers.size, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("writes during estimation schedule one follow-up and discard the stale result", async () => {
|
||||||
|
let finish;
|
||||||
|
let calls = 0;
|
||||||
|
const refresh = setup(() => {
|
||||||
|
calls++;
|
||||||
|
return new Promise((resolve) => { finish = resolve; });
|
||||||
|
});
|
||||||
|
refresh.request();
|
||||||
|
const first = refresh.tick();
|
||||||
|
for (let i = 0; i < 100; i++) refresh.request();
|
||||||
|
assert.equal(calls, 1);
|
||||||
|
assert.equal(refresh.timers.size, 0);
|
||||||
|
finish({ usage: 1 });
|
||||||
|
await first;
|
||||||
|
assert.deepEqual(refresh.results, []);
|
||||||
|
const second = refresh.tick();
|
||||||
|
finish({ usage: 2 });
|
||||||
|
await second;
|
||||||
|
assert.equal(calls, 2);
|
||||||
|
assert.deepEqual(refresh.results, [{ usage: 2 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("dispose cancels scheduled work and ignores in-flight results", async () => {
|
||||||
|
let finish;
|
||||||
|
const refresh = setup(() => new Promise((resolve) => { finish = resolve; }));
|
||||||
|
refresh.request();
|
||||||
|
refresh.dispose();
|
||||||
|
refresh.request();
|
||||||
|
assert.equal(refresh.timers.size, 0);
|
||||||
|
const active = setup(() => new Promise((resolve) => { finish = resolve; }));
|
||||||
|
active.request();
|
||||||
|
const task = active.tick();
|
||||||
|
active.dispose();
|
||||||
|
finish({ usage: 5 });
|
||||||
|
await task;
|
||||||
|
assert.deepEqual(active.results, []);
|
||||||
|
assert.equal(active.timers.size, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a failed estimate preserves the last result and allows recovery", async () => {
|
||||||
|
let fail = false;
|
||||||
|
const refresh = setup(async () => {
|
||||||
|
if (fail) throw new Error("unavailable");
|
||||||
|
return { usage: 3 };
|
||||||
|
});
|
||||||
|
refresh.request();
|
||||||
|
await refresh.tick();
|
||||||
|
fail = true;
|
||||||
|
refresh.request();
|
||||||
|
await refresh.tick();
|
||||||
|
assert.deepEqual(refresh.results, [{ usage: 3 }]);
|
||||||
|
fail = false;
|
||||||
|
refresh.request();
|
||||||
|
await refresh.tick();
|
||||||
|
assert.equal(refresh.results.length, 2);
|
||||||
|
});
|
||||||
|
|
@ -113,9 +113,9 @@ test("server and vite support local env configuration", async () => {
|
||||||
const viteConfig = await readFile("vite.config.js", "utf8");
|
const viteConfig = await readFile("vite.config.js", "utf8");
|
||||||
const envExample = await readFile(".env.example", "utf8");
|
const envExample = await readFile(".env.example", "utf8");
|
||||||
|
|
||||||
assert.match(server, /function loadEnvFile/);
|
assert.match(server, /load-project-env\.mjs/);
|
||||||
assert.match(server, /process\.env\.PORT/);
|
assert.match(server, /process\.env\.PORT/);
|
||||||
assert.match(viteConfig, /function loadEnvFile/);
|
assert.match(viteConfig, /load-project-env\.mjs/);
|
||||||
assert.match(viteConfig, /process\.env\.PORT/);
|
assert.match(viteConfig, /process\.env\.PORT/);
|
||||||
assert.match(envExample, /PORT=5173/);
|
assert.match(envExample, /PORT=5173/);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,9 @@
|
||||||
|
// Rôle : configure Vite et charge les variables locales communes au serveur.
|
||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
import { existsSync, readFileSync } from "node:fs";
|
import { loadProjectEnv } from "./scripts/load-project-env.mjs";
|
||||||
|
|
||||||
function loadEnvFile(file = ".env") {
|
loadProjectEnv();
|
||||||
if (!existsSync(file)) return;
|
|
||||||
|
|
||||||
readFileSync(file, "utf8").split(/\r?\n/).forEach((line) => {
|
|
||||||
const trimmed = line.trim();
|
|
||||||
if (!trimmed || trimmed.startsWith("#")) return;
|
|
||||||
|
|
||||||
const separatorIndex = trimmed.indexOf("=");
|
|
||||||
if (separatorIndex < 1) return;
|
|
||||||
|
|
||||||
const key = trimmed.slice(0, separatorIndex).trim();
|
|
||||||
const value = trimmed.slice(separatorIndex + 1).trim().replace(/^["']|["']$/g, "");
|
|
||||||
if (!process.env[key]) process.env[key] = value;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
loadEnvFile();
|
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
root: "website",
|
root: "website",
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,10 @@ import { useEffect, useRef, useState } from "react";
|
||||||
import { DrawingControls } from "../features/toolboxes/modules/DrawingControls.jsx";
|
import { DrawingControls } from "../features/toolboxes/modules/DrawingControls.jsx";
|
||||||
import { DrawingOverlay } from "../features/toolboxes/modules/DrawingOverlay.jsx";
|
import { DrawingOverlay } from "../features/toolboxes/modules/DrawingOverlay.jsx";
|
||||||
import { lockBodyScroll } from "../utils/bodyScrollLock.js";
|
import { lockBodyScroll } from "../utils/bodyScrollLock.js";
|
||||||
|
import { DRAWING_COLORS, DRAWING_WIDTHS } from "../utils/drawingOptions.js";
|
||||||
import { Icon } from "./Icon.jsx";
|
import { Icon } from "./Icon.jsx";
|
||||||
import { Tooltip } from "./Tooltip.jsx";
|
import { Tooltip } from "./Tooltip.jsx";
|
||||||
|
|
||||||
const DRAWING_COLORS = ["#f5f7ff", "#b4bdd3", "#f6c453", "#22d3ee", "#8b5cf6", "#d946ef"];
|
|
||||||
const DRAWING_WIDTHS = [2, 4, 8, 12];
|
|
||||||
|
|
||||||
function clampPercent(value) {
|
function clampPercent(value) {
|
||||||
const parsed = Number(value);
|
const parsed = Number(value);
|
||||||
if (!Number.isFinite(parsed)) return 0;
|
if (!Number.isFinite(parsed)) return 0;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
// Rôle : fournit l'outil Combos avec palettes d'inputs, rendu visuel et échange texte.
|
// Rôle : fournit l'outil Combos avec palettes d'inputs, rendu visuel et échange texte.
|
||||||
|
import { toggleListValue } from "../../../utils/collectionUtils.js";
|
||||||
import { getGroupedEntries } from "../../../hooks/useGroupedReorder.js";
|
import { getGroupedEntries } from "../../../hooks/useGroupedReorder.js";
|
||||||
import { TextExchangeActions } from "./TextExchangeActions.jsx";
|
import { TextExchangeActions } from "./TextExchangeActions.jsx";
|
||||||
import { CombosEditor } from "./CombosEditor.jsx";
|
import { CombosEditor } from "./CombosEditor.jsx";
|
||||||
|
|
@ -40,9 +41,7 @@ export function CombosModule({ toolboxId, moduleId, context, editing, textExchan
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleCategoryCollapsed(category) {
|
function toggleCategoryCollapsed(category) {
|
||||||
const collapsedCategories = data.collapsedCategories.includes(category)
|
const collapsedCategories = toggleListValue(data.collapsedCategories, category);
|
||||||
? data.collapsedCategories.filter((item) => item !== category)
|
|
||||||
: [...data.collapsedCategories, category];
|
|
||||||
save({ ...data, collapsedCategories });
|
save({ ...data, collapsedCategories });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
// Rôle : fournit l'outil liens avec ajout manuel et échange texte.
|
// Rôle : fournit l'outil liens avec ajout manuel et échange texte.
|
||||||
|
import { toggleListValue } from "../../../utils/collectionUtils.js";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { CategoryAutocompleteInput } from "../../../components/CategoryAutocompleteInput.jsx";
|
import { CategoryAutocompleteInput } from "../../../components/CategoryAutocompleteInput.jsx";
|
||||||
import { Icon } from "../../../components/Icon.jsx";
|
import { Icon } from "../../../components/Icon.jsx";
|
||||||
|
|
@ -143,9 +144,7 @@ export function LinksModule({ toolboxId, moduleId, context, editing, textExchang
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleCategoryCollapsed(category) {
|
function toggleCategoryCollapsed(category) {
|
||||||
const collapsedCategories = data.collapsedCategories.includes(category)
|
const collapsedCategories = toggleListValue(data.collapsedCategories, category);
|
||||||
? data.collapsedCategories.filter((item) => item !== category)
|
|
||||||
: [...data.collapsedCategories, category];
|
|
||||||
save({ ...data, collapsedCategories });
|
save({ ...data, collapsedCategories });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
// Rôle : fournit l'outil task planner avec tâches récurrentes, parents, pré requis et échange texte.
|
// Rôle : fournit l'outil task planner avec tâches récurrentes, parents, pré requis et échange texte.
|
||||||
|
import { toggleListValue } from "../../../utils/collectionUtils.js";
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useDraftForm } from "../../../hooks/useDraftForm.js";
|
import { useDraftForm } from "../../../hooks/useDraftForm.js";
|
||||||
import { TaskPlannerBranch } from "./TaskPlannerBranch.jsx";
|
import { TaskPlannerBranch } from "./TaskPlannerBranch.jsx";
|
||||||
|
|
@ -131,9 +132,7 @@ export function TaskPlannerModule({ toolboxId, moduleId, context, editing, textE
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleCategoryCollapsed(category) {
|
function toggleCategoryCollapsed(category) {
|
||||||
const collapsedCategories = data.collapsedCategories.includes(category)
|
const collapsedCategories = toggleListValue(data.collapsedCategories, category);
|
||||||
? data.collapsedCategories.filter((item) => item !== category)
|
|
||||||
: [...data.collapsedCategories, category];
|
|
||||||
save({ ...data, collapsedCategories });
|
save({ ...data, collapsedCategories });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
// Rôle : regroupe les constantes et helpers DOM du Bloc Note riche.
|
// Rôle : regroupe les constantes et helpers DOM du Bloc Note riche.
|
||||||
export const TEXT_COLORS = ["#f5f7ff", "#b4bdd3", "#f6c453", "#22d3ee", "#8b5cf6", "#d946ef"];
|
export { DRAWING_COLORS as TEXT_COLORS, DRAWING_WIDTHS } from "../../../utils/drawingOptions.js";
|
||||||
export const HIGHLIGHT_COLORS = ["#f6c453", "#22d3ee", "#8b5cf6", "#d946ef", "#202745"];
|
export const HIGHLIGHT_COLORS = ["#f6c453", "#22d3ee", "#8b5cf6", "#d946ef", "#202745"];
|
||||||
export const DRAWING_WIDTHS = [2, 4, 8, 12];
|
|
||||||
|
|
||||||
export function getPlainText(element) {
|
export function getPlainText(element) {
|
||||||
return (element?.innerText || "").replace(/\n{3,}/g, "\n\n").trim();
|
return (element?.innerText || "").replace(/\n{3,}/g, "\n\n").trim();
|
||||||
|
|
|
||||||
38
website/src/features/toolboxes/storage/quotaRefresh.js
Normal file
38
website/src/features/toolboxes/storage/quotaRefresh.js
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
// Rôle : regroupe les estimations de quota et ignore les résultats devenus obsolètes.
|
||||||
|
export function createQuotaRefresh({ estimate, onResult, delay = 250, schedule = setTimeout, cancel = clearTimeout }) {
|
||||||
|
let timer = null;
|
||||||
|
let running = false;
|
||||||
|
let dirty = false;
|
||||||
|
let disposed = false;
|
||||||
|
|
||||||
|
function request() {
|
||||||
|
if (disposed) return;
|
||||||
|
dirty = true;
|
||||||
|
if (!running && timer === null) timer = schedule(run, delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
timer = null;
|
||||||
|
if (disposed) return;
|
||||||
|
dirty = false;
|
||||||
|
running = true;
|
||||||
|
try {
|
||||||
|
const result = await estimate();
|
||||||
|
if (!disposed && !dirty) onResult(result);
|
||||||
|
} catch {
|
||||||
|
// Une estimation indisponible ne remet pas à zéro le dernier quota connu.
|
||||||
|
} finally {
|
||||||
|
running = false;
|
||||||
|
if (!disposed && dirty) request();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
request,
|
||||||
|
dispose() {
|
||||||
|
disposed = true;
|
||||||
|
if (timer !== null) cancel(timer);
|
||||||
|
timer = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
// Rôle : synchronise l'état optimiste des toolboxes avec une file d'écritures IndexedDB.
|
// Rôle : synchronise l'état optimiste des toolboxes avec une file d'écritures IndexedDB.
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
getAllModuleData as dbGetAllModuleData,
|
getAllModuleData as dbGetAllModuleData,
|
||||||
getLinks as dbGetLinks,
|
getLinks as dbGetLinks,
|
||||||
|
|
@ -22,6 +22,7 @@ import {
|
||||||
} from "./toolboxStorage.js";
|
} from "./toolboxStorage.js";
|
||||||
import { applyModuleDataValue, removeModuleDataKeys } from "./optimisticModuleData.js";
|
import { applyModuleDataValue, removeModuleDataKeys } from "./optimisticModuleData.js";
|
||||||
import { createPersistenceQueue } from "./persistenceQueue.js";
|
import { createPersistenceQueue } from "./persistenceQueue.js";
|
||||||
|
import { createQuotaRefresh } from "./quotaRefresh.js";
|
||||||
|
|
||||||
function normalizeToolboxes(toolboxes) {
|
function normalizeToolboxes(toolboxes) {
|
||||||
return compactToolboxesForStorage(toolboxes).map(normalizeToolbox).filter(Boolean);
|
return compactToolboxesForStorage(toolboxes).map(normalizeToolbox).filter(Boolean);
|
||||||
|
|
@ -32,20 +33,29 @@ export function useIndexedToolboxes(onError) {
|
||||||
const [state, setState] = useState({ toolboxes: [], links: {}, moduleData: {}, toastPosition: "right" });
|
const [state, setState] = useState({ toolboxes: [], links: {}, moduleData: {}, toastPosition: "right" });
|
||||||
const [queue] = useState(() => createPersistenceQueue(state, setState));
|
const [queue] = useState(() => createPersistenceQueue(state, setState));
|
||||||
const [storageUsage, setStorageUsage] = useState({ used: 0, limit: 0, ratio: 0 });
|
const [storageUsage, setStorageUsage] = useState({ used: 0, limit: 0, ratio: 0 });
|
||||||
|
const quotaRefreshRef = useRef(null);
|
||||||
const reportStorageError = useCallback((error) => {
|
const reportStorageError = useCallback((error) => {
|
||||||
onError(error?.message || "Erreur de stockage IndexedDB.");
|
onError(error?.message || "Erreur de stockage IndexedDB.");
|
||||||
}, [onError]);
|
}, [onError]);
|
||||||
|
|
||||||
const refreshQuota = useCallback(async () => {
|
useEffect(() => {
|
||||||
try {
|
const refresh = createQuotaRefresh({
|
||||||
const estimate = await getStorageEstimate();
|
estimate: getStorageEstimate,
|
||||||
|
onResult: (estimate) => {
|
||||||
const used = estimate.usage || 0;
|
const used = estimate.usage || 0;
|
||||||
const limit = estimate.quota || 0;
|
const limit = estimate.quota || 0;
|
||||||
setStorageUsage({ used, limit, ratio: limit ? Math.min(1, used / limit) : 0 });
|
setStorageUsage((current) => current.used === used && current.limit === limit
|
||||||
} catch {
|
? current
|
||||||
setStorageUsage({ used: 0, limit: 0, ratio: 0 });
|
: { used, limit, ratio: limit ? Math.min(1, used / limit) : 0 });
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
quotaRefreshRef.current = refresh;
|
||||||
|
return () => {
|
||||||
|
refresh.dispose();
|
||||||
|
quotaRefreshRef.current = null;
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
const refreshQuota = useCallback(() => quotaRefreshRef.current?.request(), []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
|
||||||
4
website/src/utils/collectionUtils.js
Normal file
4
website/src/utils/collectionUtils.js
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
// Rôle : fournit les modifications immuables communes aux listes de valeurs.
|
||||||
|
export function toggleListValue(values, value) {
|
||||||
|
return values.includes(value) ? values.filter((item) => item !== value) : [...values, value];
|
||||||
|
}
|
||||||
3
website/src/utils/drawingOptions.js
Normal file
3
website/src/utils/drawingOptions.js
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
// Rôle : partage les couleurs et épaisseurs des outils de dessin.
|
||||||
|
export const DRAWING_COLORS = ["#f5f7ff", "#b4bdd3", "#f6c453", "#22d3ee", "#8b5cf6", "#d946ef"];
|
||||||
|
export const DRAWING_WIDTHS = [2, 4, 8, 12];
|
||||||
Loading…
Add table
Add a link
Reference in a new issue