feat(liste): ajout des catégories d'articles avec détection automatique
Deploy HouseHub / deploy (push) Successful in 1s
Deploy HouseHub / deploy (push) Successful in 1s
- 22 catégories prédéfinies (Fruits & Légumes, Viande, Frais, etc.) - Détection automatique par dictionnaire ~250 mots-clés français - Apprentissage des corrections manuelles (pf_item_category_rules) - Groupement des articles par section dans l'interface - Badge catégorie cliquable sur chaque article avec popover de sélection - API: GET categories, POST set_category - schema_family.sql: category_id + pf_list_categories + pf_item_category_rules Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
cbb8f93435
commit
ab97af0ea3
@@ -383,13 +383,14 @@ CREATE TABLE IF NOT EXISTS pf_lists (
|
|||||||
INSERT INTO pf_lists (id, name, position) VALUES (1, 'Ma liste', 0);
|
INSERT INTO pf_lists (id, name, position) VALUES (1, 'Ma liste', 0);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS pf_grocery_items (
|
CREATE TABLE IF NOT EXISTS pf_grocery_items (
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
list_id INT NOT NULL DEFAULT 1,
|
list_id INT NOT NULL DEFAULT 1,
|
||||||
label VARCHAR(500) NOT NULL,
|
category_id INT DEFAULT NULL,
|
||||||
in_cart TINYINT(1) NOT NULL DEFAULT 0,
|
label VARCHAR(500) NOT NULL,
|
||||||
position INT NOT NULL DEFAULT 0,
|
in_cart TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
position INT NOT NULL DEFAULT 0,
|
||||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
KEY idx_items_list (list_id)
|
KEY idx_items_list (list_id)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
@@ -402,6 +403,20 @@ CREATE TABLE IF NOT EXISTS pf_grocery_history (
|
|||||||
KEY idx_hist_last (last_used_at)
|
KEY idx_hist_last (last_used_at)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS pf_list_categories (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
icon VARCHAR(10) NOT NULL DEFAULT '🏷️',
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
position INT NOT NULL DEFAULT 0
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS pf_item_category_rules (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
keyword VARCHAR(255) NOT NULL,
|
||||||
|
category_id INT NOT NULL,
|
||||||
|
UNIQUE KEY uq_keyword (keyword)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
-- ─── Calendar iOS ─────────────────────────────────────────────────────────────
|
-- ─── Calendar iOS ─────────────────────────────────────────────────────────────
|
||||||
CREATE TABLE IF NOT EXISTS pf_calendar_events (
|
CREATE TABLE IF NOT EXISTS pf_calendar_events (
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
|||||||
+309
-98
@@ -6,56 +6,62 @@ require_login();
|
|||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
set_exception_handler(function (\Throwable $e) {
|
set_exception_handler(function (\Throwable $e) {
|
||||||
if (!headers_sent()) {
|
if (!headers_sent()) { header('Content-Type: application/json'); http_response_code(500); }
|
||||||
header('Content-Type: application/json');
|
echo json_encode(['ok' => false, 'error' => $e->getMessage()]); exit;
|
||||||
http_response_code(500);
|
|
||||||
}
|
|
||||||
echo json_encode(['ok' => false, 'error' => $e->getMessage()]);
|
|
||||||
exit;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
require_once dirname(__DIR__, 2) . '/includes/db.php';
|
require_once dirname(__DIR__, 2) . '/includes/db.php';
|
||||||
|
|
||||||
// Auto-create tables
|
// ── Auto-create tables ────────────────────────────────────────────────────────
|
||||||
$pdo->exec("CREATE TABLE IF NOT EXISTS pf_lists (
|
$pdo->exec("CREATE TABLE IF NOT EXISTS pf_lists (
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(255) NOT NULL DEFAULT 'Ma liste',
|
||||||
name VARCHAR(255) NOT NULL DEFAULT 'Ma liste',
|
position INT NOT NULL DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
position INT NOT NULL DEFAULT 0,
|
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
|
||||||
|
|
||||||
$pdo->exec("CREATE TABLE IF NOT EXISTS pf_grocery_items (
|
$pdo->exec("CREATE TABLE IF NOT EXISTS pf_grocery_items (
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
id INT AUTO_INCREMENT PRIMARY KEY, list_id INT NOT NULL DEFAULT 1,
|
||||||
list_id INT NOT NULL DEFAULT 1,
|
category_id INT DEFAULT NULL, label VARCHAR(500) NOT NULL,
|
||||||
label VARCHAR(500) NOT NULL,
|
in_cart TINYINT(1) NOT NULL DEFAULT 0, position INT NOT NULL DEFAULT 0,
|
||||||
in_cart TINYINT(1) NOT NULL DEFAULT 0,
|
|
||||||
position INT NOT NULL DEFAULT 0,
|
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
KEY idx_items_list (list_id)
|
KEY idx_items_list (list_id)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
|
||||||
|
|
||||||
$pdo->exec("CREATE TABLE IF NOT EXISTS pf_grocery_history (
|
$pdo->exec("CREATE TABLE IF NOT EXISTS pf_grocery_history (
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
id INT AUTO_INCREMENT PRIMARY KEY, label_hash CHAR(64) NOT NULL,
|
||||||
label_hash CHAR(64) NOT NULL,
|
|
||||||
label_display VARCHAR(500) NOT NULL,
|
label_display VARCHAR(500) NOT NULL,
|
||||||
last_used_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
last_used_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
UNIQUE KEY uq_grocery_hist_hash (label_hash),
|
UNIQUE KEY uq_grocery_hist_hash (label_hash), KEY idx_hist_last (last_used_at)
|
||||||
KEY idx_hist_last (last_used_at)
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
|
||||||
|
|
||||||
// Add list_id column if missing (migration from old groceries table)
|
$pdo->exec("CREATE TABLE IF NOT EXISTS pf_list_categories (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY, icon VARCHAR(10) NOT NULL DEFAULT '🏷️',
|
||||||
|
name VARCHAR(100) NOT NULL, position INT NOT NULL DEFAULT 0
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
|
||||||
|
|
||||||
|
$pdo->exec("CREATE TABLE IF NOT EXISTS pf_item_category_rules (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY, keyword VARCHAR(255) NOT NULL,
|
||||||
|
category_id INT NOT NULL, UNIQUE KEY uq_keyword (keyword)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
|
||||||
|
|
||||||
|
// Migrations
|
||||||
try { $pdo->exec("ALTER TABLE pf_grocery_items ADD COLUMN list_id INT NOT NULL DEFAULT 1 AFTER id"); } catch (\Exception $e) {}
|
try { $pdo->exec("ALTER TABLE pf_grocery_items ADD COLUMN list_id INT NOT NULL DEFAULT 1 AFTER id"); } catch (\Exception $e) {}
|
||||||
|
try { $pdo->exec("ALTER TABLE pf_grocery_items ADD COLUMN category_id INT DEFAULT NULL AFTER list_id"); } catch (\Exception $e) {}
|
||||||
try { $pdo->exec("ALTER TABLE pf_grocery_items ADD KEY idx_items_list (list_id)"); } catch (\Exception $e) {}
|
try { $pdo->exec("ALTER TABLE pf_grocery_items ADD KEY idx_items_list (list_id)"); } catch (\Exception $e) {}
|
||||||
|
|
||||||
$action = $_GET['action'] ?? $_POST['action'] ?? '';
|
// ── Seed categories if empty ──────────────────────────────────────────────────
|
||||||
$method = $_SERVER['REQUEST_METHOD'];
|
if ((int)$pdo->query("SELECT COUNT(*) FROM pf_list_categories")->fetchColumn() === 0) {
|
||||||
|
$cats = [
|
||||||
$body = [];
|
['🍎','Fruits & Légumes'],['🍖','Viande'],['🐟','Poissonnerie'],
|
||||||
if ($method === 'PUT' || $method === 'POST') {
|
['🍞','Boulangerie'],['🥛','Frais'],['❄️','Surgelés'],
|
||||||
$raw = file_get_contents('php://input');
|
['🧃','Boissons'],['🌾','Pâtes, Riz, Féculents'],['🥜','Épicerie salée'],
|
||||||
if ($raw) $body = json_decode($raw, true) ?? [];
|
['🥫','Conserves'],['🍛','Plats cuisinés'],['🧂','Sauces & Condiments'],
|
||||||
foreach ($_POST as $k => $v) if (!isset($body[$k])) $body[$k] = $v;
|
['🥐','Petit déjeuner'],['🍪','Biscuits & Gâteaux'],['🍫','Confiserie'],
|
||||||
|
['🍦','Dessert'],['🧴','Beauté & Hygiène'],['🍼','Bébé'],
|
||||||
|
['🧼','Entretien'],['🐶','Animaux'],['🏡','Maison & Jardin'],['💊','Pharmacie'],
|
||||||
|
];
|
||||||
|
$ins = $pdo->prepare("INSERT INTO pf_list_categories (icon, name, position) VALUES (?,?,?)");
|
||||||
|
foreach ($cats as $i => $c) $ins->execute([$c[0], $c[1], $i * 10]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
@@ -64,25 +70,246 @@ function liste_normalize(string $s): string {
|
|||||||
return mb_strtolower(trim(mb_substr($s, 0, 500)));
|
return mb_strtolower(trim(mb_substr($s, 0, 500)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function liste_normalize_detect(string $s): string {
|
||||||
|
$s = mb_strtolower(trim($s));
|
||||||
|
$from = ['é','è','ê','ë','à','â','ä','ù','û','ü','î','ï','ô','ö','ç','œ','æ'];
|
||||||
|
$to = ['e','e','e','e','a','a','a','u','u','u','i','i','o','o','c','oe','ae'];
|
||||||
|
$s = str_replace($from, $to, $s);
|
||||||
|
return trim(preg_replace('/[^a-z0-9 ]/', '', $s));
|
||||||
|
}
|
||||||
|
|
||||||
function liste_touch_history(PDO $pdo, string $label): void {
|
function liste_touch_history(PDO $pdo, string $label): void {
|
||||||
$hash = hash('sha256', liste_normalize($label));
|
$hash = hash('sha256', liste_normalize($label));
|
||||||
$pdo->prepare("INSERT INTO pf_grocery_history (label_hash, label_display)
|
$pdo->prepare("INSERT INTO pf_grocery_history (label_hash, label_display) VALUES (?,?)
|
||||||
VALUES (?, ?)
|
|
||||||
ON DUPLICATE KEY UPDATE label_display=VALUES(label_display), last_used_at=NOW()")
|
ON DUPLICATE KEY UPDATE label_display=VALUES(label_display), last_used_at=NOW()")
|
||||||
->execute([$hash, trim($label)]);
|
->execute([$hash, trim($label)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function liste_ensure_default(PDO $pdo): int {
|
function liste_ensure_default(PDO $pdo): int {
|
||||||
$count = (int) $pdo->query("SELECT COUNT(*) FROM pf_lists")->fetchColumn();
|
if ((int)$pdo->query("SELECT COUNT(*) FROM pf_lists")->fetchColumn() === 0) {
|
||||||
if ($count === 0) {
|
|
||||||
$pdo->exec("INSERT INTO pf_lists (name, position) VALUES ('Ma liste', 0)");
|
$pdo->exec("INSERT INTO pf_lists (name, position) VALUES ('Ma liste', 0)");
|
||||||
return (int) $pdo->lastInsertId();
|
return (int)$pdo->lastInsertId();
|
||||||
}
|
}
|
||||||
return (int) $pdo->query("SELECT id FROM pf_lists ORDER BY position, id LIMIT 1")->fetchColumn();
|
return (int)$pdo->query("SELECT id FROM pf_lists ORDER BY position, id LIMIT 1")->fetchColumn();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Built-in keyword → category position mapping (position maps to seeded order above)
|
||||||
|
function liste_builtin_detect(string $normalized): ?int {
|
||||||
|
static $map = null;
|
||||||
|
if ($map === null) $map = [
|
||||||
|
// 1 = Fruits & Légumes
|
||||||
|
'pomme'=>1,'poire'=>1,'banane'=>1,'orange'=>1,'citron'=>1,'raisin'=>1,
|
||||||
|
'fraise'=>1,'framboise'=>1,'myrtille'=>1,'cerise'=>1,'peche'=>1,'abricot'=>1,
|
||||||
|
'mangue'=>1,'kiwi'=>1,'ananas'=>1,'melon'=>1,'pasteque'=>1,'prune'=>1,
|
||||||
|
'tomate'=>1,'concombre'=>1,'carotte'=>1,'oignon'=>1,'poireau'=>1,
|
||||||
|
'courgette'=>1,'aubergine'=>1,'poivron'=>1,'salade'=>1,'laitue'=>1,
|
||||||
|
'epinard'=>1,'brocoli'=>1,'chou'=>1,'choux'=>1,'haricot'=>1,'pois'=>1,
|
||||||
|
'champignon'=>1,'ail'=>1,'echalote'=>1,'pomme de terre'=>1,'patate'=>1,
|
||||||
|
'navet'=>1,'radis'=>1,'betterave'=>1,'fenouil'=>1,'asperge'=>1,
|
||||||
|
'artichaut'=>1,'celeri'=>1,'butternut'=>1,'courge'=>1,'potiron'=>1,
|
||||||
|
'gingembre'=>1,'avocat'=>1,'endive'=>1,'ciboulette'=>1,'persil'=>1,
|
||||||
|
'basilic'=>1,'menthe'=>1,'thym'=>1,'romarin'=>1,'coriandre'=>1,
|
||||||
|
'girofle'=>1,'citron vert'=>1,'lime'=>1,'pamplemousse'=>1,'litchi'=>1,
|
||||||
|
// 2 = Viande
|
||||||
|
'boeuf'=>2,'veau'=>2,'poulet'=>2,'porc'=>2,'agneau'=>2,'dinde'=>2,
|
||||||
|
'lapin'=>2,'jambon'=>2,'lardon'=>2,'saucisse'=>2,'saucisson'=>2,
|
||||||
|
'merguez'=>2,'chipolata'=>2,'steak'=>2,'escalope'=>2,'roti'=>2,
|
||||||
|
'gigot'=>2,'filet'=>2,'cuisses'=>2,'viande hachee'=>2,'andouille'=>2,
|
||||||
|
'magret'=>2,'canard'=>2,'foie'=>2,'boudin'=>2,'paupiette'=>2,
|
||||||
|
'entrecote'=>2,'cote de boeuf'=>2,'onglet'=>2,'bavette'=>2,
|
||||||
|
// 3 = Poissonnerie
|
||||||
|
'saumon'=>3,'thon'=>3,'dorade'=>3,'bar'=>3,'cabillaud'=>3,'sole'=>3,
|
||||||
|
'truite'=>3,'lieu'=>3,'maquereau'=>3,'sardine'=>3,'anchois'=>3,
|
||||||
|
'crevette'=>3,'moule'=>3,'huitre'=>3,'coquille'=>3,'poulpe'=>3,
|
||||||
|
'seiche'=>3,'langoustine'=>3,'lotte'=>3,'merlu'=>3,'colin'=>3,
|
||||||
|
'tilapia'=>3,'daurade'=>3,'aiglefin'=>3,'homard'=>3,'crabe'=>3,
|
||||||
|
// 4 = Boulangerie
|
||||||
|
'pain'=>4,'baguette'=>4,'brioche'=>4,'croissant'=>4,'pain de mie'=>4,
|
||||||
|
'ficelle'=>4,'fougasse'=>4,'ciabatta'=>4,'biscotte'=>4,'naan'=>4,
|
||||||
|
'pita'=>4,'toast'=>4,'bagel'=>4,'pain burger'=>4,'wrap'=>4,
|
||||||
|
// 5 = Frais
|
||||||
|
'lait'=>5,'creme'=>5,'yaourt'=>5,'yourt'=>5,'beurre'=>5,
|
||||||
|
'fromage'=>5,'camembert'=>5,'brie'=>5,'comte'=>5,'gruyere'=>5,
|
||||||
|
'emmental'=>5,'mozzarella'=>5,'ricotta'=>5,'mascarpone'=>5,
|
||||||
|
'feta'=>5,'chevre'=>5,'oeuf'=>5,'oeufs'=>5,'creme fraiche'=>5,
|
||||||
|
'fromage blanc'=>5,'petit suisse'=>5,'kefir'=>5,
|
||||||
|
// 6 = Surgelés
|
||||||
|
'surgele'=>6,'pizza surgelee'=>6,'frite surgelee'=>6,'poisson pane'=>6,
|
||||||
|
'legume surgele'=>6,'glace'=>6,'sorbet'=>6,'plat surgele'=>6,
|
||||||
|
'nugget'=>6,'edamame'=>6,'petits pois'=>6,'epinards'=>6,
|
||||||
|
// 7 = Boissons
|
||||||
|
'eau'=>7,'coca'=>7,'cola'=>7,'limonade'=>7,'sirop'=>7,
|
||||||
|
'biere'=>7,'vin'=>7,'champagne'=>7,'cafe'=>7,'the'=>7,
|
||||||
|
'tisane'=>7,'soda'=>7,'jus'=>7,'kombucha'=>7,'cidre'=>7,
|
||||||
|
'whisky'=>7,'rhum'=>7,'vodka'=>7,'prosecco'=>7,'rose'=>7,
|
||||||
|
// 8 = Pâtes, Riz, Féculents
|
||||||
|
'pate'=>8,'spaghetti'=>8,'tagliatelle'=>8,'penne'=>8,'fusilli'=>8,
|
||||||
|
'macaroni'=>8,'riz'=>8,'couscous'=>8,'quinoa'=>8,'lentille'=>8,
|
||||||
|
'pois chiche'=>8,'farine'=>8,'semoule'=>8,'polenta'=>8,'boulgour'=>8,
|
||||||
|
'orge'=>8,'feculent'=>8,'vermicelle'=>8,'nouille'=>8,'gnocchi'=>8,
|
||||||
|
// 9 = Épicerie salée
|
||||||
|
'chips'=>9,'crackers'=>9,'olives'=>9,'cornichon'=>9,'capres'=>9,
|
||||||
|
'tapenade'=>9,'houmous'=>9,'tzatziki'=>9,'sel'=>9,'poivre'=>9,
|
||||||
|
'epice'=>9,'cube bouillon'=>9,'bouillon'=>9,'noix de cajou'=>9,
|
||||||
|
'amande'=>9,'pistache'=>9,'noix'=>9,'cacahuete'=>9,
|
||||||
|
// 10 = Conserves
|
||||||
|
'conserve'=>10,'boite de tomate'=>10,'concentre de tomate'=>10,
|
||||||
|
'mais'=>10,'ratatouille'=>10,'cassoulet'=>10,'pate de foie'=>10,
|
||||||
|
'sardine boite'=>10,'thon boite'=>10,'maquereau boite'=>10,
|
||||||
|
'soupe boite'=>10,'haricot boite'=>10,
|
||||||
|
// 11 = Plats cuisinés
|
||||||
|
'quiche'=>11,'lasagne'=>11,'gratin'=>11,'pizza'=>11,'tartiflette'=>11,
|
||||||
|
'croque'=>11,'hachis parmentier'=>11,'paella'=>11,'moussaka'=>11,
|
||||||
|
'tarte'=>11,'flamiche'=>11,'pissaladiere'=>11,
|
||||||
|
// 12 = Sauces & Condiments
|
||||||
|
'ketchup'=>12,'mayonnaise'=>12,'moutarde'=>12,'vinaigrette'=>12,
|
||||||
|
'sauce soja'=>12,'tabasco'=>12,'huile'=>12,'vinaigre'=>12,
|
||||||
|
'sauce tomate'=>12,'pesto'=>12,'sriracha'=>12,'worcester'=>12,
|
||||||
|
'nuoc mam'=>12,'sauce'=>12,'condiment'=>12,'worcestershire'=>12,
|
||||||
|
// 13 = Petit déjeuner
|
||||||
|
'cereale'=>13,'muesli'=>13,'granola'=>13,'corn flakes'=>13,
|
||||||
|
'miel'=>13,'confiture'=>13,'nutella'=>13,'beurre de cacahuete'=>13,
|
||||||
|
'sirop d erable'=>13,'sirop erable'=>13,'chocolat en poudre'=>13,
|
||||||
|
// 14 = Biscuits & Gâteaux
|
||||||
|
'biscuit'=>14,'gateau'=>14,'cookie'=>14,'madeleine'=>14,
|
||||||
|
'financier'=>14,'quatre quarts'=>14,'brownie'=>14,'sable'=>14,
|
||||||
|
'speculoos'=>14,'oreo'=>14,'lu'=>14,'petit beurre'=>14,'galette'=>14,
|
||||||
|
'palmier'=>14,'macaron'=>14,'eclair'=>14,
|
||||||
|
// 15 = Confiserie
|
||||||
|
'chocolat'=>15,'bonbon'=>15,'reglisse'=>15,'caramel'=>15,
|
||||||
|
'nougat'=>15,'marshmallow'=>15,'guimauve'=>15,'sucette'=>15,
|
||||||
|
'chewing gum'=>15,'pastille'=>15,'calisson'=>15,
|
||||||
|
// 16 = Dessert
|
||||||
|
'yaourt dessert'=>16,'creme dessert'=>16,'mousse au chocolat'=>16,
|
||||||
|
'tiramisu'=>16,'creme brulee'=>16,'flan'=>16,'crepe'=>16,
|
||||||
|
'madeleine'=>16,'profiterole'=>16,'eclair'=>16,
|
||||||
|
// 17 = Beauté & Hygiène
|
||||||
|
'shampoing'=>17,'gel douche'=>17,'savon'=>17,'dentifrice'=>17,
|
||||||
|
'deodorant'=>17,'crème visage'=>17,'crème corps'=>17,'rasoir'=>17,
|
||||||
|
'mousse a raser'=>17,'coton'=>17,'lingette'=>17,'maquillage'=>17,
|
||||||
|
'parfum'=>17,'brosse a dents'=>17,'fil dentaire'=>17,'serum'=>17,
|
||||||
|
'hydratant'=>17,'demaquillant'=>17,
|
||||||
|
// 18 = Bébé
|
||||||
|
'couche'=>18,'biberon'=>18,'lait infantile'=>18,'compote bebe'=>18,
|
||||||
|
'pot bebe'=>18,'puree bebe'=>18,'lingette bebe'=>18,'savon bebe'=>18,
|
||||||
|
'creme bebe'=>18,'sucette bebe'=>18,
|
||||||
|
// 19 = Entretien
|
||||||
|
'lessive'=>19,'liquide vaisselle'=>19,'nettoyant'=>19,'degraissant'=>19,
|
||||||
|
'deboucheur'=>19,'anticalcaire'=>19,'eponge'=>19,'serpillere'=>19,
|
||||||
|
'sac poubelle'=>19,'papier toilette'=>19,'sopalin'=>19,'essuie tout'=>19,
|
||||||
|
'aluminium'=>19,'film plastique'=>19,'sac congelation'=>19,
|
||||||
|
'nettoyant wc'=>19,'desinfectant'=>19,'vitre'=>19,'javel'=>19,
|
||||||
|
// 20 = Animaux
|
||||||
|
'croquette'=>20,'patee'=>20,'litiere'=>20,'os'=>20,
|
||||||
|
'friandise animale'=>20,'nourriture chat'=>20,'nourriture chien'=>20,
|
||||||
|
// 21 = Maison & Jardin
|
||||||
|
'ampoule'=>21,'pile'=>21,'bougie'=>21,'allumette'=>21,
|
||||||
|
'ruban adhesif'=>21,'colle'=>21,'vis'=>21,'terreau'=>21,
|
||||||
|
'engrais'=>21,'pot de fleur'=>21,'arrosoir'=>21,'graine'=>21,
|
||||||
|
'tournevis'=>21,'marteau'=>21,'clou'=>21,'cle'=>21,
|
||||||
|
// 22 = Pharmacie
|
||||||
|
'doliprane'=>22,'ibuprofene'=>22,'paracetamol'=>22,'aspirine'=>22,
|
||||||
|
'bandage'=>22,'pansement'=>22,'thermometre'=>22,'serum physiologique'=>22,
|
||||||
|
'vitamine'=>22,'complement'=>22,'masque'=>22,'gant medical'=>22,
|
||||||
|
'sirop'=>22,'antihistaminique'=>22,'antidouleur'=>22,
|
||||||
|
];
|
||||||
|
|
||||||
|
// Check full phrase
|
||||||
|
if (isset($map[$normalized])) return $map[$normalized];
|
||||||
|
|
||||||
|
// Check individual words
|
||||||
|
$words = explode(' ', $normalized);
|
||||||
|
foreach ($words as $w) {
|
||||||
|
if (strlen($w) >= 3 && isset($map[$w])) return $map[$w];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Partial match (contains)
|
||||||
|
foreach ($map as $kw => $catPos) {
|
||||||
|
if (str_contains($normalized, $kw)) return $catPos;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function liste_detect_category(PDO $pdo, string $label): ?int {
|
||||||
|
$norm = liste_normalize_detect($label);
|
||||||
|
|
||||||
|
// 1. Learned rules (exact normalized label)
|
||||||
|
$r = $pdo->prepare("SELECT category_id FROM pf_item_category_rules WHERE keyword=? LIMIT 1");
|
||||||
|
$r->execute([$norm]);
|
||||||
|
if ($row = $r->fetch()) return (int)$row['category_id'];
|
||||||
|
|
||||||
|
// 2. Learned rules (individual words)
|
||||||
|
foreach (explode(' ', $norm) as $w) {
|
||||||
|
if (strlen($w) < 3) continue;
|
||||||
|
$r = $pdo->prepare("SELECT category_id FROM pf_item_category_rules WHERE keyword=? LIMIT 1");
|
||||||
|
$r->execute([$w]);
|
||||||
|
if ($row = $r->fetch()) return (int)$row['category_id'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Built-in dictionary → map position to real category id
|
||||||
|
$pos = liste_builtin_detect($norm);
|
||||||
|
if ($pos === null) return null;
|
||||||
|
|
||||||
|
// Get category id by position order
|
||||||
|
$r = $pdo->query("SELECT id FROM pf_list_categories ORDER BY position, id");
|
||||||
|
$ids = $r->fetchAll(PDO::FETCH_COLUMN);
|
||||||
|
return $ids[$pos - 1] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function liste_learn(PDO $pdo, string $label, int $category_id): void {
|
||||||
|
$norm = liste_normalize_detect($label);
|
||||||
|
if (!$norm) return;
|
||||||
|
$pdo->prepare("INSERT INTO pf_item_category_rules (keyword, category_id) VALUES (?,?)
|
||||||
|
ON DUPLICATE KEY UPDATE category_id=VALUES(category_id)")
|
||||||
|
->execute([$norm, $category_id]);
|
||||||
|
// Also learn individual meaningful words
|
||||||
|
foreach (explode(' ', $norm) as $w) {
|
||||||
|
if (strlen($w) >= 4) {
|
||||||
|
$pdo->prepare("INSERT IGNORE INTO pf_item_category_rules (keyword, category_id) VALUES (?,?)")
|
||||||
|
->execute([$w, $category_id]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$action = $_GET['action'] ?? $_POST['action'] ?? '';
|
||||||
|
$method = $_SERVER['REQUEST_METHOD'];
|
||||||
|
$body = [];
|
||||||
|
if ($method === 'PUT' || $method === 'POST') {
|
||||||
|
$raw = file_get_contents('php://input');
|
||||||
|
if ($raw) $body = json_decode($raw, true) ?? [];
|
||||||
|
foreach ($_POST as $k => $v) if (!isset($body[$k])) $body[$k] = $v;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── CATEGORIES ────────────────────────────────────────────────────────────────
|
||||||
|
if ($action === 'categories' && $method === 'GET') {
|
||||||
|
$rows = $pdo->query("SELECT id, icon, name, position FROM pf_list_categories ORDER BY position, id")->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
echo json_encode(['categories' => $rows]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SET CATEGORY (+ learn) ────────────────────────────────────────────────────
|
||||||
|
if ($action === 'set_category' && $method === 'POST') {
|
||||||
|
$item_id = (int)($body['item_id'] ?? 0);
|
||||||
|
$cat_id_raw = $body['category_id'] ?? null;
|
||||||
|
$category_id = ($cat_id_raw === null || $cat_id_raw === '' || $cat_id_raw === '0') ? null : (int)$cat_id_raw;
|
||||||
|
|
||||||
|
if (!$item_id) { http_response_code(400); echo json_encode(['error' => 'item_id required']); exit; }
|
||||||
|
|
||||||
|
$pdo->prepare("UPDATE pf_grocery_items SET category_id=? WHERE id=?")->execute([$category_id, $item_id]);
|
||||||
|
|
||||||
|
// Learn from manual assignment
|
||||||
|
if ($category_id !== null) {
|
||||||
|
$row = $pdo->prepare("SELECT label FROM pf_grocery_items WHERE id=?");
|
||||||
|
$row->execute([$item_id]);
|
||||||
|
if ($r = $row->fetch()) liste_learn($pdo, $r['label'], $category_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
echo json_encode(['ok' => true]);
|
||||||
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── LISTS ─────────────────────────────────────────────────────────────────────
|
// ── LISTS ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
if ($action === 'lists') {
|
if ($action === 'lists') {
|
||||||
if ($method === 'GET') {
|
if ($method === 'GET') {
|
||||||
$firstId = liste_ensure_default($pdo);
|
$firstId = liste_ensure_default($pdo);
|
||||||
@@ -91,125 +318,109 @@ if ($action === 'lists') {
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
if ($method === 'POST') {
|
if ($method === 'POST') {
|
||||||
$name = trim($body['name'] ?? '');
|
$name = mb_substr(trim($body['name'] ?? ''), 0, 100);
|
||||||
if ($name === '') { http_response_code(400); echo json_encode(['error' => 'name required']); exit; }
|
if (!$name) { http_response_code(400); echo json_encode(['error' => 'name required']); exit; }
|
||||||
$name = mb_substr($name, 0, 100);
|
$pos = (int)$pdo->query("SELECT COALESCE(MAX(position),0)+1 FROM pf_lists")->fetchColumn();
|
||||||
$s = $pdo->query("SELECT COALESCE(MAX(position),0)+1 FROM pf_lists");
|
$pdo->prepare("INSERT INTO pf_lists (name, position) VALUES (?,?)")->execute([$name, $pos]);
|
||||||
$pos = (int) $s->fetchColumn();
|
echo json_encode(['id' => (int)$pdo->lastInsertId(), 'name' => $name, 'position' => $pos]);
|
||||||
$pdo->prepare("INSERT INTO pf_lists (name, position) VALUES (?, ?)")->execute([$name, $pos]);
|
|
||||||
$id = (int) $pdo->lastInsertId();
|
|
||||||
echo json_encode(['id' => $id, 'name' => $name, 'position' => $pos]);
|
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
if ($method === 'PUT') {
|
if ($method === 'PUT') {
|
||||||
$id = (int) ($_GET['id'] ?? 0);
|
$id = (int)($_GET['id'] ?? 0);
|
||||||
$name = trim($body['name'] ?? '');
|
$name = mb_substr(trim($body['name'] ?? ''), 0, 100);
|
||||||
if (!$id || $name === '') { http_response_code(400); echo json_encode(['error' => 'invalid']); exit; }
|
if (!$id || !$name) { http_response_code(400); echo json_encode(['error' => 'invalid']); exit; }
|
||||||
$pdo->prepare("UPDATE pf_lists SET name=? WHERE id=?")->execute([mb_substr($name,0,100), $id]);
|
$pdo->prepare("UPDATE pf_lists SET name=? WHERE id=?")->execute([$name, $id]);
|
||||||
echo json_encode(['ok' => true]);
|
echo json_encode(['ok' => true]); exit;
|
||||||
exit;
|
|
||||||
}
|
}
|
||||||
if ($method === 'DELETE') {
|
if ($method === 'DELETE') {
|
||||||
$id = (int) ($_GET['id'] ?? 0);
|
$id = (int)($_GET['id'] ?? 0);
|
||||||
$count = (int) $pdo->query("SELECT COUNT(*) FROM pf_lists")->fetchColumn();
|
if ((int)$pdo->query("SELECT COUNT(*) FROM pf_lists")->fetchColumn() <= 1) {
|
||||||
if ($count <= 1) { http_response_code(400); echo json_encode(['error' => 'cannot delete last list']); exit; }
|
http_response_code(400); echo json_encode(['error' => 'cannot delete last list']); exit;
|
||||||
|
}
|
||||||
$pdo->prepare("DELETE FROM pf_grocery_items WHERE list_id=?")->execute([$id]);
|
$pdo->prepare("DELETE FROM pf_grocery_items WHERE list_id=?")->execute([$id]);
|
||||||
$pdo->prepare("DELETE FROM pf_lists WHERE id=?")->execute([$id]);
|
$pdo->prepare("DELETE FROM pf_lists WHERE id=?")->execute([$id]);
|
||||||
echo json_encode(['ok' => true]);
|
echo json_encode(['ok' => true]); exit;
|
||||||
exit;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── ITEMS ─────────────────────────────────────────────────────────────────────
|
// ── ITEMS ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
if ($action === 'items') {
|
if ($action === 'items') {
|
||||||
$list_id = (int) ($_GET['list_id'] ?? $body['list_id'] ?? 0);
|
$list_id = (int)($_GET['list_id'] ?? $body['list_id'] ?? 0);
|
||||||
|
|
||||||
if ($method === 'GET') {
|
if ($method === 'GET') {
|
||||||
if (!$list_id) { http_response_code(400); echo json_encode(['error' => 'list_id required']); exit; }
|
if (!$list_id) { http_response_code(400); echo json_encode(['error' => 'list_id required']); exit; }
|
||||||
$s = $pdo->prepare("SELECT id, list_id, label, in_cart, position FROM pf_grocery_items WHERE list_id=? ORDER BY in_cart, position, id");
|
$s = $pdo->prepare("SELECT id, list_id, category_id, label, in_cart, position FROM pf_grocery_items WHERE list_id=? ORDER BY in_cart, position, id");
|
||||||
$s->execute([$list_id]);
|
$s->execute([$list_id]);
|
||||||
echo json_encode(['items' => $s->fetchAll(PDO::FETCH_ASSOC)]);
|
echo json_encode(['items' => $s->fetchAll(PDO::FETCH_ASSOC)]); exit;
|
||||||
exit;
|
|
||||||
}
|
}
|
||||||
if ($method === 'POST') {
|
if ($method === 'POST') {
|
||||||
$label = trim($body['label'] ?? '');
|
$label = trim($body['label'] ?? '');
|
||||||
if (!$list_id || $label === '') { http_response_code(400); echo json_encode(['error' => 'missing fields']); exit; }
|
if (!$list_id || !$label) { http_response_code(400); echo json_encode(['error' => 'missing fields']); exit; }
|
||||||
$norm = liste_normalize($label);
|
$norm = liste_normalize($label);
|
||||||
$dup = $pdo->prepare("SELECT COUNT(*) FROM pf_grocery_items WHERE list_id=? AND LOWER(TRIM(label))=?");
|
$dup = $pdo->prepare("SELECT COUNT(*) FROM pf_grocery_items WHERE list_id=? AND LOWER(TRIM(label))=?");
|
||||||
$dup->execute([$list_id, $norm]);
|
$dup->execute([$list_id, $norm]);
|
||||||
if ((int)$dup->fetchColumn() > 0) { echo json_encode(['duplicate' => true]); exit; }
|
if ((int)$dup->fetchColumn() > 0) { echo json_encode(['duplicate' => true]); exit; }
|
||||||
$q = $pdo->prepare("SELECT COALESCE(MAX(position),0)+1 FROM pf_grocery_items WHERE list_id=?");
|
$q = $pdo->prepare("SELECT COALESCE(MAX(position),0)+1 FROM pf_grocery_items WHERE list_id=?");
|
||||||
$q->execute([$list_id]);
|
$q->execute([$list_id]);
|
||||||
$pos = (int) $q->fetchColumn();
|
$pos = (int)$q->fetchColumn();
|
||||||
$pdo->prepare("INSERT INTO pf_grocery_items (list_id, label, in_cart, position) VALUES (?,?,0,?)")->execute([$list_id, $label, $pos]);
|
$category_id = liste_detect_category($pdo, $label);
|
||||||
$id = (int) $pdo->lastInsertId();
|
$pdo->prepare("INSERT INTO pf_grocery_items (list_id, category_id, label, in_cart, position) VALUES (?,?,?,0,?)")->execute([$list_id, $category_id, $label, $pos]);
|
||||||
|
$id = (int)$pdo->lastInsertId();
|
||||||
liste_touch_history($pdo, $label);
|
liste_touch_history($pdo, $label);
|
||||||
echo json_encode(['id' => $id, 'list_id' => $list_id, 'label' => $label, 'in_cart' => 0, 'position' => $pos]);
|
echo json_encode(['id' => $id, 'list_id' => $list_id, 'category_id' => $category_id, 'label' => $label, 'in_cart' => 0, 'position' => $pos]); exit;
|
||||||
exit;
|
|
||||||
}
|
}
|
||||||
if ($method === 'PUT') {
|
if ($method === 'PUT') {
|
||||||
$id = (int) ($_GET['id'] ?? 0);
|
$id = (int)($_GET['id'] ?? 0);
|
||||||
if (!$id) { http_response_code(400); echo json_encode(['error' => 'id required']); exit; }
|
if (!$id) { http_response_code(400); echo json_encode(['error' => 'id required']); exit; }
|
||||||
if (isset($body['in_cart'])) {
|
if (isset($body['in_cart'])) $pdo->prepare("UPDATE pf_grocery_items SET in_cart=? WHERE id=?")->execute([(int)$body['in_cart'], $id]);
|
||||||
$pdo->prepare("UPDATE pf_grocery_items SET in_cart=? WHERE id=?")->execute([(int)$body['in_cart'], $id]);
|
|
||||||
}
|
|
||||||
if (isset($body['label'])) {
|
if (isset($body['label'])) {
|
||||||
$label = trim($body['label']);
|
$label = trim($body['label']);
|
||||||
if ($label !== '') { $pdo->prepare("UPDATE pf_grocery_items SET label=? WHERE id=?")->execute([$label, $id]); liste_touch_history($pdo, $label); }
|
if ($label) {
|
||||||
|
$pdo->prepare("UPDATE pf_grocery_items SET label=? WHERE id=?")->execute([$label, $id]);
|
||||||
|
liste_touch_history($pdo, $label);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
echo json_encode(['ok' => true]);
|
echo json_encode(['ok' => true]); exit;
|
||||||
exit;
|
|
||||||
}
|
}
|
||||||
if ($method === 'DELETE') {
|
if ($method === 'DELETE') {
|
||||||
$id = (int) ($_GET['id'] ?? 0);
|
$id = (int)($_GET['id'] ?? 0);
|
||||||
if (!$id) { http_response_code(400); echo json_encode(['error' => 'id required']); exit; }
|
if (!$id) { http_response_code(400); echo json_encode(['error' => 'id required']); exit; }
|
||||||
$r = $pdo->prepare("SELECT label FROM pf_grocery_items WHERE id=?");
|
$r = $pdo->prepare("SELECT label FROM pf_grocery_items WHERE id=?"); $r->execute([$id]);
|
||||||
$r->execute([$id]);
|
|
||||||
if ($row = $r->fetch()) liste_touch_history($pdo, $row['label']);
|
if ($row = $r->fetch()) liste_touch_history($pdo, $row['label']);
|
||||||
$pdo->prepare("DELETE FROM pf_grocery_items WHERE id=?")->execute([$id]);
|
$pdo->prepare("DELETE FROM pf_grocery_items WHERE id=?")->execute([$id]);
|
||||||
echo json_encode(['ok' => true]);
|
echo json_encode(['ok' => true]); exit;
|
||||||
exit;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── BULK ──────────────────────────────────────────────────────────────────────
|
// ── BULK ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
if ($action === 'uncheck_all' && $method === 'POST') {
|
if ($action === 'uncheck_all' && $method === 'POST') {
|
||||||
$list_id = (int)($body['list_id'] ?? 0);
|
$lid = (int)($body['list_id'] ?? 0);
|
||||||
if ($list_id) $pdo->prepare("UPDATE pf_grocery_items SET in_cart=0 WHERE list_id=?")->execute([$list_id]);
|
if ($lid) $pdo->prepare("UPDATE pf_grocery_items SET in_cart=0 WHERE list_id=?")->execute([$lid]);
|
||||||
echo json_encode(['ok' => true]); exit;
|
echo json_encode(['ok' => true]); exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($action === 'delete_picked' && $method === 'POST') {
|
if ($action === 'delete_picked' && $method === 'POST') {
|
||||||
$list_id = (int)($body['list_id'] ?? 0);
|
$lid = (int)($body['list_id'] ?? 0);
|
||||||
if ($list_id) {
|
if ($lid) {
|
||||||
$rows = $pdo->prepare("SELECT label FROM pf_grocery_items WHERE list_id=? AND in_cart=1");
|
$rows = $pdo->prepare("SELECT label FROM pf_grocery_items WHERE list_id=? AND in_cart=1"); $rows->execute([$lid]);
|
||||||
$rows->execute([$list_id]);
|
|
||||||
foreach ($rows->fetchAll() as $r) liste_touch_history($pdo, $r['label']);
|
foreach ($rows->fetchAll() as $r) liste_touch_history($pdo, $r['label']);
|
||||||
$pdo->prepare("DELETE FROM pf_grocery_items WHERE list_id=? AND in_cart=1")->execute([$list_id]);
|
$pdo->prepare("DELETE FROM pf_grocery_items WHERE list_id=? AND in_cart=1")->execute([$lid]);
|
||||||
}
|
}
|
||||||
echo json_encode(['ok' => true]); exit;
|
echo json_encode(['ok' => true]); exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($action === 'clear_all' && $method === 'POST') {
|
if ($action === 'clear_all' && $method === 'POST') {
|
||||||
$list_id = (int)($body['list_id'] ?? 0);
|
$lid = (int)($body['list_id'] ?? 0);
|
||||||
if ($list_id) {
|
if ($lid) {
|
||||||
$rows = $pdo->prepare("SELECT label FROM pf_grocery_items WHERE list_id=?");
|
$rows = $pdo->prepare("SELECT label FROM pf_grocery_items WHERE list_id=?"); $rows->execute([$lid]);
|
||||||
$rows->execute([$list_id]);
|
|
||||||
foreach ($rows->fetchAll() as $r) liste_touch_history($pdo, $r['label']);
|
foreach ($rows->fetchAll() as $r) liste_touch_history($pdo, $r['label']);
|
||||||
$pdo->prepare("DELETE FROM pf_grocery_items WHERE list_id=?")->execute([$list_id]);
|
$pdo->prepare("DELETE FROM pf_grocery_items WHERE list_id=?")->execute([$lid]);
|
||||||
}
|
}
|
||||||
echo json_encode(['ok' => true]); exit;
|
echo json_encode(['ok' => true]); exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── HISTORY ───────────────────────────────────────────────────────────────────
|
// ── HISTORY ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
if ($action === 'history' && $method === 'GET') {
|
if ($action === 'history' && $method === 'GET') {
|
||||||
$max = 20;
|
$max = 20;
|
||||||
$note = $pdo->prepare("SELECT content FROM pf_notes WHERE note_type='setting' AND reference_id='liste_history_max'");
|
$n = $pdo->prepare("SELECT content FROM pf_notes WHERE note_type='setting' AND reference_id='liste_history_max'"); $n->execute();
|
||||||
$note->execute();
|
if ($r = $n->fetch()) $max = max(1, min(50, (int)$r['content']));
|
||||||
if ($n = $note->fetch()) $max = max(1, min(50, (int)$n['content']));
|
|
||||||
$s = $pdo->query("SELECT label_display FROM pf_grocery_history ORDER BY last_used_at DESC LIMIT $max");
|
$s = $pdo->query("SELECT label_display FROM pf_grocery_history ORDER BY last_used_at DESC LIMIT $max");
|
||||||
echo json_encode(['history' => $s->fetchAll(PDO::FETCH_COLUMN)]); exit;
|
echo json_encode(['history' => $s->fetchAll(PDO::FETCH_COLUMN)]); exit;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -310,6 +310,110 @@
|
|||||||
}
|
}
|
||||||
.liste-empty-icon { font-size: 3rem; margin-bottom: .75rem; }
|
.liste-empty-icon { font-size: 3rem; margin-bottom: .75rem; }
|
||||||
|
|
||||||
|
/* ── Category section headers ──────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.liste-cat-section-header {
|
||||||
|
font-size: .75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--muted, #6c757d);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .05em;
|
||||||
|
padding: .6rem .25rem .2rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .4rem;
|
||||||
|
}
|
||||||
|
.liste-cat-section-header:first-child { padding-top: .1rem; }
|
||||||
|
|
||||||
|
.liste-cat-section-count {
|
||||||
|
background: var(--muted-bg, #e9ecef);
|
||||||
|
color: var(--muted, #6c757d);
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: .7rem;
|
||||||
|
padding: .05rem .4rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0;
|
||||||
|
text-transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Category badge (per-item) ─────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.liste-cat-badge {
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--border, #dee2e6);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: .1rem .3rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: .85rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: inherit;
|
||||||
|
transition: border-color .15s, background .15s;
|
||||||
|
}
|
||||||
|
.liste-cat-badge:hover {
|
||||||
|
border-color: var(--primary, #4361ee);
|
||||||
|
background: var(--primary-light, #eef0fd);
|
||||||
|
}
|
||||||
|
.liste-cat-badge-empty {
|
||||||
|
opacity: .35;
|
||||||
|
border-style: dashed;
|
||||||
|
}
|
||||||
|
.liste-row:hover .liste-cat-badge-empty { opacity: .7; }
|
||||||
|
|
||||||
|
/* ── Category picker popover ───────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.liste-cat-picker {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 9999;
|
||||||
|
background: var(--card-bg, #fff);
|
||||||
|
border: 1px solid var(--border, #dee2e6);
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 8px 32px rgba(0,0,0,.15);
|
||||||
|
width: 216px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.liste-cat-picker-title {
|
||||||
|
font-size: .72rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .05em;
|
||||||
|
color: var(--muted, #6c757d);
|
||||||
|
padding: .6rem .75rem .35rem;
|
||||||
|
border-bottom: 1px solid var(--border, #dee2e6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.liste-cat-picker-list {
|
||||||
|
overflow-y: auto;
|
||||||
|
max-height: 300px;
|
||||||
|
padding: .3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.liste-cat-picker-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .4rem;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: .4rem .6rem;
|
||||||
|
font-size: .83rem;
|
||||||
|
color: var(--text, #212529);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background .1s;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.liste-cat-picker-item:hover { background: var(--hover-bg, #f8f9fa); }
|
||||||
|
.liste-cat-picker-item.active {
|
||||||
|
background: var(--primary-light, #eef0fd);
|
||||||
|
color: var(--primary, #4361ee);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Dark mode ─────────────────────────────────────────────────────────────── */
|
/* ── Dark mode ─────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
[data-theme='dark'] .liste-tab { background: #1e1e2e; border-color: #3a3a4e; color: #cdd6f4; }
|
[data-theme='dark'] .liste-tab { background: #1e1e2e; border-color: #3a3a4e; color: #cdd6f4; }
|
||||||
@@ -325,3 +429,11 @@
|
|||||||
[data-theme='dark'] .btn-tool:hover { background: #2a2a3e; }
|
[data-theme='dark'] .btn-tool:hover { background: #2a2a3e; }
|
||||||
[data-theme='dark'] .liste-new-form input { background: #1e1e2e; color: #cdd6f4; border-color: #4361ee; }
|
[data-theme='dark'] .liste-new-form input { background: #1e1e2e; color: #cdd6f4; border-color: #4361ee; }
|
||||||
[data-theme='dark'] .liste-new-form #btn-cancel-new-list { background: #2a2a3e; color: #cdd6f4; }
|
[data-theme='dark'] .liste-new-form #btn-cancel-new-list { background: #2a2a3e; color: #cdd6f4; }
|
||||||
|
[data-theme='dark'] .liste-cat-badge { border-color: #3a3a4e; }
|
||||||
|
[data-theme='dark'] .liste-cat-badge:hover { background: #313155; border-color: #4361ee; }
|
||||||
|
[data-theme='dark'] .liste-cat-section-count { background: #2a2a3e; color: #8892b0; }
|
||||||
|
[data-theme='dark'] .liste-cat-picker { background: #1e1e2e; border-color: #3a3a4e; }
|
||||||
|
[data-theme='dark'] .liste-cat-picker-title { border-color: #3a3a4e; }
|
||||||
|
[data-theme='dark'] .liste-cat-picker-item { color: #cdd6f4; }
|
||||||
|
[data-theme='dark'] .liste-cat-picker-item:hover { background: #2a2a3e; }
|
||||||
|
[data-theme='dark'] .liste-cat-picker-item.active { background: #313155; color: #89b4fa; }
|
||||||
|
|||||||
+115
-15
@@ -1,4 +1,4 @@
|
|||||||
/* invraw — Liste module JS */
|
/* Liste module JS */
|
||||||
const API = '/modules/liste/api.php';
|
const API = '/modules/liste/api.php';
|
||||||
const T = window.LISTE_TRANSLATIONS || {};
|
const T = window.LISTE_TRANSLATIONS || {};
|
||||||
|
|
||||||
@@ -7,9 +7,11 @@ const state = {
|
|||||||
currentListId: null,
|
currentListId: null,
|
||||||
items: [],
|
items: [],
|
||||||
history: [],
|
history: [],
|
||||||
|
categories: [],
|
||||||
|
catMap: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Utilities ─────────────────────────────────────────────────────────────────
|
// ── Utilities ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function esc(s) {
|
function esc(s) {
|
||||||
return String(s ?? '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
return String(s ?? '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||||
@@ -40,7 +42,67 @@ async function api(action, method = 'GET', body = null, params = {}) {
|
|||||||
return r.json();
|
return r.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Tabs ──────────────────────────────────────────────────────────────────────
|
// ── Categories ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function loadCategories() {
|
||||||
|
if (state.categories.length) return;
|
||||||
|
const r = await api('categories');
|
||||||
|
state.categories = r.categories || [];
|
||||||
|
state.catMap = {};
|
||||||
|
state.categories.forEach(c => { state.catMap[c.id] = c; });
|
||||||
|
}
|
||||||
|
|
||||||
|
let activePicker = null;
|
||||||
|
|
||||||
|
function openCategoryPicker(itemId, anchorEl) {
|
||||||
|
closeCategoryPicker();
|
||||||
|
const item = state.items.find(i => i.id === itemId);
|
||||||
|
if (!item) return;
|
||||||
|
|
||||||
|
const picker = document.createElement('div');
|
||||||
|
picker.className = 'liste-cat-picker';
|
||||||
|
picker.innerHTML = `
|
||||||
|
<div class="liste-cat-picker-title">Catégorie</div>
|
||||||
|
<div class="liste-cat-picker-list">
|
||||||
|
<button class="liste-cat-picker-item${!item.category_id ? ' active' : ''}" data-cat="0">🏷️ Non classé</button>
|
||||||
|
${state.categories.map(c =>
|
||||||
|
`<button class="liste-cat-picker-item${item.category_id == c.id ? ' active' : ''}" data-cat="${c.id}">${esc(c.icon)} ${esc(c.name)}</button>`
|
||||||
|
).join('')}
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
const rect = anchorEl.getBoundingClientRect();
|
||||||
|
let top = rect.bottom + 4;
|
||||||
|
let left = rect.left;
|
||||||
|
if (left + 220 > window.innerWidth) left = window.innerWidth - 228;
|
||||||
|
if (top + 340 > window.innerHeight) top = rect.top - Math.min(340, top + 340 - window.innerHeight) - 4;
|
||||||
|
picker.style.top = top + 'px';
|
||||||
|
picker.style.left = left + 'px';
|
||||||
|
|
||||||
|
document.body.appendChild(picker);
|
||||||
|
activePicker = picker;
|
||||||
|
|
||||||
|
picker.querySelectorAll('.liste-cat-picker-item').forEach(btn => {
|
||||||
|
btn.addEventListener('click', async e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const catId = parseInt(btn.dataset.cat) || null;
|
||||||
|
await api('set_category', 'POST', { item_id: itemId, category_id: catId });
|
||||||
|
const it = state.items.find(i => i.id === itemId);
|
||||||
|
if (it) it.category_id = catId;
|
||||||
|
closeCategoryPicker();
|
||||||
|
renderItems();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
document.addEventListener('click', closeCategoryPicker, { once: true });
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeCategoryPicker() {
|
||||||
|
if (activePicker) { activePicker.remove(); activePicker = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tabs ───────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function renderTabs() {
|
function renderTabs() {
|
||||||
const container = document.getElementById('liste-tabs');
|
const container = document.getElementById('liste-tabs');
|
||||||
@@ -64,11 +126,9 @@ function renderTabs() {
|
|||||||
container.appendChild(tab);
|
container.appendChild(tab);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Rename button
|
|
||||||
container.querySelectorAll('.liste-tab-rename').forEach(btn => {
|
container.querySelectorAll('.liste-tab-rename').forEach(btn => {
|
||||||
btn.addEventListener('click', e => { e.stopPropagation(); startRename(parseInt(btn.dataset.id)); });
|
btn.addEventListener('click', e => { e.stopPropagation(); startRename(parseInt(btn.dataset.id)); });
|
||||||
});
|
});
|
||||||
// Delete button
|
|
||||||
container.querySelectorAll('.liste-tab-delete').forEach(btn => {
|
container.querySelectorAll('.liste-tab-delete').forEach(btn => {
|
||||||
btn.addEventListener('click', e => { e.stopPropagation(); deleteList(parseInt(btn.dataset.id)); });
|
btn.addEventListener('click', e => { e.stopPropagation(); deleteList(parseInt(btn.dataset.id)); });
|
||||||
});
|
});
|
||||||
@@ -101,7 +161,7 @@ async function deleteList(listId) {
|
|||||||
toast(T.list_deleted || 'Liste supprimée');
|
toast(T.list_deleted || 'Liste supprimée');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── List management ────────────────────────────────────────────────────────────
|
// ── List management ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
document.getElementById('btn-add-list')?.addEventListener('click', () => {
|
document.getElementById('btn-add-list')?.addEventListener('click', () => {
|
||||||
document.getElementById('liste-new-form')?.classList.remove('hidden');
|
document.getElementById('liste-new-form')?.classList.remove('hidden');
|
||||||
@@ -136,7 +196,7 @@ async function confirmNewList() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Switch list ────────────────────────────────────────────────────────────────
|
// ── Switch list ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function switchList(listId) {
|
async function switchList(listId) {
|
||||||
state.currentListId = listId;
|
state.currentListId = listId;
|
||||||
@@ -144,7 +204,7 @@ async function switchList(listId) {
|
|||||||
renderTabs();
|
renderTabs();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Items ──────────────────────────────────────────────────────────────────────
|
// ── Items ───────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function loadItems() {
|
async function loadItems() {
|
||||||
if (!state.currentListId) {
|
if (!state.currentListId) {
|
||||||
@@ -168,12 +228,44 @@ function renderItems() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (toolbar) toolbar.style.display = '';
|
if (toolbar) toolbar.style.display = '';
|
||||||
|
|
||||||
const pending = state.items.filter(i => !i.in_cart);
|
const pending = state.items.filter(i => !i.in_cart);
|
||||||
const inCart = state.items.filter(i => i.in_cart);
|
const inCart = state.items.filter(i => i.in_cart);
|
||||||
|
const hasCategories = pending.some(i => i.category_id);
|
||||||
|
|
||||||
let html = '';
|
let html = '';
|
||||||
if (pending.length) html += pending.map(rowHtml).join('');
|
|
||||||
if (inCart.length) html += `<div class="liste-cart-divider">Dans le panier (${inCart.length})</div>` + inCart.map(rowHtml).join('');
|
if (hasCategories && state.categories.length) {
|
||||||
|
// Group pending items by category
|
||||||
|
const groups = new Map();
|
||||||
|
pending.forEach(item => {
|
||||||
|
const key = item.category_id ?? 0;
|
||||||
|
if (!groups.has(key)) groups.set(key, []);
|
||||||
|
groups.get(key).push(item);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Render in category order, uncategorized last
|
||||||
|
const catIds = state.categories.map(c => parseInt(c.id));
|
||||||
|
const orderedKeys = catIds.filter(id => groups.has(id));
|
||||||
|
if (groups.has(0)) orderedKeys.push(0);
|
||||||
|
|
||||||
|
orderedKeys.forEach(key => {
|
||||||
|
const items = groups.get(key);
|
||||||
|
const cat = key ? state.catMap[key] : null;
|
||||||
|
const label = cat ? `${cat.icon} ${cat.name}` : '🏷️ Non classé';
|
||||||
|
html += `<div class="liste-cat-section-header">${esc(label)} <span class="liste-cat-section-count">${items.length}</span></div>`;
|
||||||
|
html += items.map(rowHtml).join('');
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
html = pending.map(rowHtml).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inCart.length) {
|
||||||
|
html += `<div class="liste-cart-divider">Dans le panier (${inCart.length})</div>` + inCart.map(rowHtml).join('');
|
||||||
|
}
|
||||||
|
|
||||||
root.innerHTML = html;
|
root.innerHTML = html;
|
||||||
|
|
||||||
root.querySelectorAll('.liste-check').forEach(cb => {
|
root.querySelectorAll('.liste-check').forEach(cb => {
|
||||||
cb.addEventListener('change', () => toggleCart(parseInt(cb.dataset.id), cb.checked ? 1 : 0));
|
cb.addEventListener('change', () => toggleCart(parseInt(cb.dataset.id), cb.checked ? 1 : 0));
|
||||||
});
|
});
|
||||||
@@ -183,12 +275,20 @@ function renderItems() {
|
|||||||
root.querySelectorAll('.btn-delete-item').forEach(btn => {
|
root.querySelectorAll('.btn-delete-item').forEach(btn => {
|
||||||
btn.addEventListener('click', () => deleteItem(parseInt(btn.dataset.id)));
|
btn.addEventListener('click', () => deleteItem(parseInt(btn.dataset.id)));
|
||||||
});
|
});
|
||||||
|
root.querySelectorAll('.liste-cat-badge').forEach(btn => {
|
||||||
|
btn.addEventListener('click', e => { e.stopPropagation(); openCategoryPicker(parseInt(btn.dataset.id), btn); });
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function rowHtml(item) {
|
function rowHtml(item) {
|
||||||
const checked = item.in_cart ? 'checked' : '';
|
const checked = item.in_cart ? 'checked' : '';
|
||||||
const cls = item.in_cart ? 'liste-row in-cart' : 'liste-row';
|
const cls = item.in_cart ? 'liste-row in-cart' : 'liste-row';
|
||||||
|
const cat = item.category_id ? state.catMap[item.category_id] : null;
|
||||||
|
const badgeCls = 'liste-cat-badge' + (cat ? '' : ' liste-cat-badge-empty');
|
||||||
|
const badgeIcon = cat ? esc(cat.icon) : '🏷️';
|
||||||
|
const badgeTip = cat ? esc(cat.name) : 'Non classé';
|
||||||
return `<div class="${cls}" data-id="${item.id}">
|
return `<div class="${cls}" data-id="${item.id}">
|
||||||
|
<button class="${badgeCls}" data-id="${item.id}" title="${badgeTip}">${badgeIcon}</button>
|
||||||
<label class="liste-check-label">
|
<label class="liste-check-label">
|
||||||
<input type="checkbox" class="liste-check" data-id="${item.id}" ${checked}>
|
<input type="checkbox" class="liste-check" data-id="${item.id}" ${checked}>
|
||||||
<span class="liste-label">${esc(item.label)}</span>
|
<span class="liste-label">${esc(item.label)}</span>
|
||||||
@@ -261,7 +361,7 @@ document.getElementById('btn-clear-all')?.addEventListener('click', async () =>
|
|||||||
toast(T.deleted || 'Liste vidée');
|
toast(T.deleted || 'Liste vidée');
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Add input ──────────────────────────────────────────────────────────────────
|
// ── Add input ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
document.getElementById('btn-liste-add')?.addEventListener('click', () => {
|
document.getElementById('btn-liste-add')?.addEventListener('click', () => {
|
||||||
const input = document.getElementById('liste-input');
|
const input = document.getElementById('liste-input');
|
||||||
@@ -301,12 +401,12 @@ function renderHistory() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Init ───────────────────────────────────────────────────────────────────────
|
// ── Init ────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function init() {
|
async function init() {
|
||||||
const r = await api('lists');
|
const [listsR] = await Promise.all([api('lists'), loadCategories()]);
|
||||||
state.lists = r.lists || [];
|
state.lists = listsR.lists || [];
|
||||||
state.currentListId = r.default_id || state.lists[0]?.id || null;
|
state.currentListId = listsR.default_id || state.lists[0]?.id || null;
|
||||||
renderTabs();
|
renderTabs();
|
||||||
await Promise.all([loadItems(), loadHistory()]);
|
await Promise.all([loadItems(), loadHistory()]);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user