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-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-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
|
||||
|
||||
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
|
||||
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
|
||||
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`.
|
||||
|
||||
```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.
|
||||
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 { extname, join, relative, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
|
@ -10,24 +11,7 @@ const distRoot = join(websiteRoot, "dist");
|
|||
const root = existsSync(distRoot) ? distRoot : websiteRoot;
|
||||
const publicRoot = existsSync(distRoot) ? distRoot : join(websiteRoot, "public");
|
||||
|
||||
function loadEnvFile(file = ".env") {
|
||||
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();
|
||||
loadProjectEnv();
|
||||
|
||||
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 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(viteConfig, /function loadEnvFile/);
|
||||
assert.match(viteConfig, /load-project-env\.mjs/);
|
||||
assert.match(viteConfig, /process\.env\.PORT/);
|
||||
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 react from "@vitejs/plugin-react";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { loadProjectEnv } from "./scripts/load-project-env.mjs";
|
||||
|
||||
function loadEnvFile(file = ".env") {
|
||||
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();
|
||||
loadProjectEnv();
|
||||
|
||||
export default defineConfig({
|
||||
root: "website",
|
||||
|
|
|
|||
|
|
@ -3,12 +3,10 @@ import { useEffect, useRef, useState } from "react";
|
|||
import { DrawingControls } from "../features/toolboxes/modules/DrawingControls.jsx";
|
||||
import { DrawingOverlay } from "../features/toolboxes/modules/DrawingOverlay.jsx";
|
||||
import { lockBodyScroll } from "../utils/bodyScrollLock.js";
|
||||
import { DRAWING_COLORS, DRAWING_WIDTHS } from "../utils/drawingOptions.js";
|
||||
import { Icon } from "./Icon.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) {
|
||||
const parsed = Number(value);
|
||||
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.
|
||||
import { toggleListValue } from "../../../utils/collectionUtils.js";
|
||||
import { getGroupedEntries } from "../../../hooks/useGroupedReorder.js";
|
||||
import { TextExchangeActions } from "./TextExchangeActions.jsx";
|
||||
import { CombosEditor } from "./CombosEditor.jsx";
|
||||
|
|
@ -40,9 +41,7 @@ export function CombosModule({ toolboxId, moduleId, context, editing, textExchan
|
|||
}
|
||||
|
||||
function toggleCategoryCollapsed(category) {
|
||||
const collapsedCategories = data.collapsedCategories.includes(category)
|
||||
? data.collapsedCategories.filter((item) => item !== category)
|
||||
: [...data.collapsedCategories, category];
|
||||
const collapsedCategories = toggleListValue(data.collapsedCategories, category);
|
||||
save({ ...data, collapsedCategories });
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
// Rôle : fournit l'outil liens avec ajout manuel et échange texte.
|
||||
import { toggleListValue } from "../../../utils/collectionUtils.js";
|
||||
import { useState } from "react";
|
||||
import { CategoryAutocompleteInput } from "../../../components/CategoryAutocompleteInput.jsx";
|
||||
import { Icon } from "../../../components/Icon.jsx";
|
||||
|
|
@ -143,9 +144,7 @@ export function LinksModule({ toolboxId, moduleId, context, editing, textExchang
|
|||
}
|
||||
|
||||
function toggleCategoryCollapsed(category) {
|
||||
const collapsedCategories = data.collapsedCategories.includes(category)
|
||||
? data.collapsedCategories.filter((item) => item !== category)
|
||||
: [...data.collapsedCategories, category];
|
||||
const collapsedCategories = toggleListValue(data.collapsedCategories, category);
|
||||
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.
|
||||
import { toggleListValue } from "../../../utils/collectionUtils.js";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useDraftForm } from "../../../hooks/useDraftForm.js";
|
||||
import { TaskPlannerBranch } from "./TaskPlannerBranch.jsx";
|
||||
|
|
@ -131,9 +132,7 @@ export function TaskPlannerModule({ toolboxId, moduleId, context, editing, textE
|
|||
}
|
||||
|
||||
function toggleCategoryCollapsed(category) {
|
||||
const collapsedCategories = data.collapsedCategories.includes(category)
|
||||
? data.collapsedCategories.filter((item) => item !== category)
|
||||
: [...data.collapsedCategories, category];
|
||||
const collapsedCategories = toggleListValue(data.collapsedCategories, category);
|
||||
save({ ...data, collapsedCategories });
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
// 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 DRAWING_WIDTHS = [2, 4, 8, 12];
|
||||
|
||||
export function getPlainText(element) {
|
||||
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.
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
getAllModuleData as dbGetAllModuleData,
|
||||
getLinks as dbGetLinks,
|
||||
|
|
@ -22,6 +22,7 @@ import {
|
|||
} from "./toolboxStorage.js";
|
||||
import { applyModuleDataValue, removeModuleDataKeys } from "./optimisticModuleData.js";
|
||||
import { createPersistenceQueue } from "./persistenceQueue.js";
|
||||
import { createQuotaRefresh } from "./quotaRefresh.js";
|
||||
|
||||
function normalizeToolboxes(toolboxes) {
|
||||
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 [queue] = useState(() => createPersistenceQueue(state, setState));
|
||||
const [storageUsage, setStorageUsage] = useState({ used: 0, limit: 0, ratio: 0 });
|
||||
const quotaRefreshRef = useRef(null);
|
||||
const reportStorageError = useCallback((error) => {
|
||||
onError(error?.message || "Erreur de stockage IndexedDB.");
|
||||
}, [onError]);
|
||||
|
||||
const refreshQuota = useCallback(async () => {
|
||||
try {
|
||||
const estimate = await getStorageEstimate();
|
||||
const used = estimate.usage || 0;
|
||||
const limit = estimate.quota || 0;
|
||||
setStorageUsage({ used, limit, ratio: limit ? Math.min(1, used / limit) : 0 });
|
||||
} catch {
|
||||
setStorageUsage({ used: 0, limit: 0, ratio: 0 });
|
||||
}
|
||||
useEffect(() => {
|
||||
const refresh = createQuotaRefresh({
|
||||
estimate: getStorageEstimate,
|
||||
onResult: (estimate) => {
|
||||
const used = estimate.usage || 0;
|
||||
const limit = estimate.quota || 0;
|
||||
setStorageUsage((current) => current.used === used && current.limit === limit
|
||||
? current
|
||||
: { 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(() => {
|
||||
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