judu/re-group-firefox-addonpublic Fork
⑂ f8a4d25
Commits
⬇ Clone ▾
git clone https://git.rickub.com/judu/re-group-firefox-addon.git
git clone ssh://git@rickub.com/judu/re-group-firefox-addon.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Download ZIP

Release 0.0.1Unverified

Julien Durillon committed 2026-06-03T22:57:10+02:00 Browse files
f8a4d25 parent: b9768cf
added .gitignore +1 -0
new file mode 100644
@@ -0,0 +1 @@
1+/web-ext-artifacts/
new file mode 100644
@@ -0,0 +1 @@
1+/web-ext-artifacts/
added README.md +67 -0
new file mode 100644
@@ -0,0 +1,67 @@
1+# re:group
2+
3+Extension Firefox (cible **151+**) qui range automatiquement les onglets dans des
4+groupes prédéfinis selon des expressions régulières sur les URLs.
5+
6+La spécification complète du comportement est dans [`SPEC.md`](./SPEC.md).
7+
8+## Fonctionnement
9+
10+- Tu définis des **groupes** (nom + couleur + liste de regexp) dans la page de
11+ configuration.
12+- Quand un onglet (http/https, non épinglé) charge une URL qui matche les regexp
13+ d'un groupe, il est placé dans ce groupe.
14+- En cas de match multiple, gagne la regexp dont la **sous-chaîne matchée est la
15+ plus longue**.
16+- Si tu déplaces un onglet à la main, l'extension respecte ton choix jusqu'à ce que
17+ l'URL change vers un **autre** groupe.
18+- Les onglets qui ne matchent rien ne sont jamais retirés d'un groupe.
19+- Les groupes sont **par fenêtre** ; les groupes vides sont supprimés par Firefox.
20+
21+## Structure
22+
23+```
24+manifest.json Manifest V3, perms : tabs / tabGroups / storage
25+background.js Point d'entrée (event page) → branche les listeners
26+src/matcher.js Logique pure URL → groupe (testable hors navigateur)
27+src/config.js Modèle + persistance (storage.local), validation
28+src/state.js État runtime (storage.session) : instances, override
29+src/grouper.js Moteur : placement, déplacement, ré-organisation
30+src/i18n.js Helper i18n + applicateur DOM (data-i18n)
31+options/ Page de configuration (HTML/CSS/JS vanilla)
32+_locales/ Traductions (fr par défaut, en)
33+test/ Tests unitaires du matcher (node --test)
34+```
35+
36+## Langues
37+
38+L'interface est traduite via l'API i18n des WebExtensions (`_locales/fr` et
39+`_locales/en`). Firefox choisit la langue selon la langue de son interface, avec
40+**fr** comme langue par défaut (`default_locale`). Pour ajouter une langue, créer
41+`_locales/<code>/messages.json` en copiant les clés d'un fichier existant.
42+
43+## Développement
44+
45+Lancer Firefox avec l'extension chargée (rechargement à chaud) :
46+
47+```bash
48+npx web-ext run
49+```
50+
51+Ou charger manuellement : `about:debugging` → **Ce Firefox** → *Charger un module
52+complémentaire temporaire* → choisir `manifest.json`.
53+
54+### Vérifier
55+
56+```bash
57+node --test test/matcher.test.mjs # tests de la logique de matching
58+npx web-ext lint --self-hosted # validation du manifest / des APIs
59+```
60+
61+## Distribution
62+
63+Signature privée (« unlisted ») via AMO :
64+
65+```bash
66+npx web-ext sign --channel=unlisted --api-key=… --api-secret=…
67+```
new file mode 100644
@@ -0,0 +1,67 @@
1+# re:group
2+
3+Extension Firefox (cible **151+**) qui range automatiquement les onglets dans des
4+groupes prédéfinis selon des expressions régulières sur les URLs.
5+
6+La spécification complète du comportement est dans [`SPEC.md`](./SPEC.md).
7+
8+## Fonctionnement
9+
10+- Tu définis des **groupes** (nom + couleur + liste de regexp) dans la page de
11+ configuration.
12+- Quand un onglet (http/https, non épinglé) charge une URL qui matche les regexp
13+ d'un groupe, il est placé dans ce groupe.
14+- En cas de match multiple, gagne la regexp dont la **sous-chaîne matchée est la
15+ plus longue**.
16+- Si tu déplaces un onglet à la main, l'extension respecte ton choix jusqu'à ce que
17+ l'URL change vers un **autre** groupe.
18+- Les onglets qui ne matchent rien ne sont jamais retirés d'un groupe.
19+- Les groupes sont **par fenêtre** ; les groupes vides sont supprimés par Firefox.
20+
21+## Structure
22+
23+```
24+manifest.json Manifest V3, perms : tabs / tabGroups / storage
25+background.js Point d'entrée (event page) → branche les listeners
26+src/matcher.js Logique pure URL → groupe (testable hors navigateur)
27+src/config.js Modèle + persistance (storage.local), validation
28+src/state.js État runtime (storage.session) : instances, override
29+src/grouper.js Moteur : placement, déplacement, ré-organisation
30+src/i18n.js Helper i18n + applicateur DOM (data-i18n)
31+options/ Page de configuration (HTML/CSS/JS vanilla)
32+_locales/ Traductions (fr par défaut, en)
33+test/ Tests unitaires du matcher (node --test)
34+```
35+
36+## Langues
37+
38+L'interface est traduite via l'API i18n des WebExtensions (`_locales/fr` et
39+`_locales/en`). Firefox choisit la langue selon la langue de son interface, avec
40+**fr** comme langue par défaut (`default_locale`). Pour ajouter une langue, créer
41+`_locales/<code>/messages.json` en copiant les clés d'un fichier existant.
42+
43+## Développement
44+
45+Lancer Firefox avec l'extension chargée (rechargement à chaud) :
46+
47+```bash
48+npx web-ext run
49+```
50+
51+Ou charger manuellement : `about:debugging` → **Ce Firefox** → *Charger un module
52+complémentaire temporaire* → choisir `manifest.json`.
53+
54+### Vérifier
55+
56+```bash
57+node --test test/matcher.test.mjs # tests de la logique de matching
58+npx web-ext lint --self-hosted # validation du manifest / des APIs
59+```
60+
61+## Distribution
62+
63+Signature privée (« unlisted ») via AMO :
64+
65+```bash
66+npx web-ext sign --channel=unlisted --api-key=… --api-secret=…
67+```
added SPEC.md +84 -0
new file mode 100644
@@ -0,0 +1,84 @@
1+# Spec — Extension Firefox « auto-group »
2+
3+## Objectif
4+
5+Extension Firefox (cible **151+**) qui range automatiquement les onglets dans
6+des groupes d'onglets pré-définis, selon des expressions régulières appliquées
7+sur les URLs.
8+
9+## Permissions
10+
11+- `tabs`
12+- `tabGroups`
13+- `storage`
14+
15+Pas davantage.
16+
17+## Modèle de configuration (page d'options)
18+
19+- Liste de **groupes pré-définis**. Chaque groupe a :
20+ - un **nom** ;
21+ - une **couleur** (palette Firefox) ;
22+ - une **liste de regexp** sur les URLs.
23+- **Validation live** des regexp : l'enregistrement est **bloqué** tant qu'une
24+ regexp est invalide (message d'erreur clair à la saisie).
25+- Les regexp sont **insensibles à la casse** par défaut (flag `i`), testées en
26+ **match partiel** (`regex.test(url)`) sur l'**URL complète**.
27+- **Export / import JSON** de la configuration (backup, partage entre machines).
28+- Stockage de la config dans `storage.local`.
29+- Bouton **« Ré-organiser maintenant »** qui traite les onglets déjà ouverts à
30+ la demande. Il vit **dans la page de config** (pas de popup toolbar).
31+
32+## Comportement de regroupement
33+
34+- Déclenché sur **toute navigation, y compris SPA** (history.pushState, hash).
35+- **Onglets épinglés ignorés.** Si un onglet géré devient épinglé → il est
36+ **retiré du groupe** et n'est plus géré.
37+- **Seulement les URLs http/https.** Toute autre URL (`about:`, `file:`,
38+ `moz-extension:`, `view-source:`…) est ignorée — jamais groupée.
39+- **Conflit multi-match** : si l'URL matche les regexp de plusieurs groupes,
40+ gagne la regexp dont la **sous-chaîne effectivement matchée est la plus
41+ longue**.
42+- Onglet qui **ne matche aucun groupe** → **ignoré**, et **jamais retiré** d'un
43+ groupe où il se trouverait déjà.
44+- Onglet déjà dans un groupe dont l'URL change :
45+ - si la nouvelle URL matche un **autre** groupe → **déplacé** vers ce groupe ;
46+ - si la nouvelle URL **ne matche aucun** groupe → **reste** dans le groupe
47+ actuel.
48+
49+## Multi-fenêtres
50+
51+- Les groupes Firefox sont **par fenêtre**.
52+- Stratégie : **un groupe par fenêtre, créé à la demande** — le groupe d'une
53+ config est instancié dans une fenêtre la première fois qu'un onglet de cette
54+ fenêtre y matche.
55+- Les onglets **ne traversent pas** les fenêtres ; chaque onglet rejoint
56+ l'instance du groupe dans **sa propre** fenêtre.
57+- Quand le **dernier onglet** quitte un groupe géré (fermé ou déplacé), le
58+ **groupe vide est supprimé**. Il sera recréé à la demande au prochain match.
59+
60+## Override manuel
61+
62+- Si l'utilisateur déplace un onglet manuellement (hors du groupe, ou vers un
63+ autre groupe), l'extension **respecte ce choix** et ne re-déplace plus
64+ l'onglet.
65+- Cet override est levé **lorsque l'URL de l'onglet change vers un nouveau
66+ match** (vers un groupe différent du groupe courant).
67+- L'état d'override est stocké en **`storage.session`** : il survit à la mise en
68+ veille de l'event page (MV3), mais **pas** au redémarrage de Firefox. Pas de
69+ re-tri automatique au démarrage.
70+
71+## Choix techniques
72+
73+- **Manifest V3**, background **non-persistant** (event page).
74+- **Vanilla JS / HTML / CSS**, **sans étape de build**.
75+- État volatile (override manuel) en **`storage.session`**.
76+- Distribution : **signée via AMO en "unlisted"** (installation privée durable,
77+ sans publication publique).
78+
79+## Point à confirmer
80+
81+- Condition exacte de **reset de l'override** : interprétée comme « l'override
82+ saute dès que l'URL change et matche un groupe **différent** du groupe
83+ courant » (un re-match vers le *même* groupe ne fait rien). À valider à
84+ l'implémentation.
new file mode 100644
@@ -0,0 +1,84 @@
1+# Spec — Extension Firefox « auto-group »
2+
3+## Objectif
4+
5+Extension Firefox (cible **151+**) qui range automatiquement les onglets dans
6+des groupes d'onglets pré-définis, selon des expressions régulières appliquées
7+sur les URLs.
8+
9+## Permissions
10+
11+- `tabs`
12+- `tabGroups`
13+- `storage`
14+
15+Pas davantage.
16+
17+## Modèle de configuration (page d'options)
18+
19+- Liste de **groupes pré-définis**. Chaque groupe a :
20+ - un **nom** ;
21+ - une **couleur** (palette Firefox) ;
22+ - une **liste de regexp** sur les URLs.
23+- **Validation live** des regexp : l'enregistrement est **bloqué** tant qu'une
24+ regexp est invalide (message d'erreur clair à la saisie).
25+- Les regexp sont **insensibles à la casse** par défaut (flag `i`), testées en
26+ **match partiel** (`regex.test(url)`) sur l'**URL complète**.
27+- **Export / import JSON** de la configuration (backup, partage entre machines).
28+- Stockage de la config dans `storage.local`.
29+- Bouton **« Ré-organiser maintenant »** qui traite les onglets déjà ouverts à
30+ la demande. Il vit **dans la page de config** (pas de popup toolbar).
31+
32+## Comportement de regroupement
33+
34+- Déclenché sur **toute navigation, y compris SPA** (history.pushState, hash).
35+- **Onglets épinglés ignorés.** Si un onglet géré devient épinglé → il est
36+ **retiré du groupe** et n'est plus géré.
37+- **Seulement les URLs http/https.** Toute autre URL (`about:`, `file:`,
38+ `moz-extension:`, `view-source:`…) est ignorée — jamais groupée.
39+- **Conflit multi-match** : si l'URL matche les regexp de plusieurs groupes,
40+ gagne la regexp dont la **sous-chaîne effectivement matchée est la plus
41+ longue**.
42+- Onglet qui **ne matche aucun groupe** → **ignoré**, et **jamais retiré** d'un
43+ groupe où il se trouverait déjà.
44+- Onglet déjà dans un groupe dont l'URL change :
45+ - si la nouvelle URL matche un **autre** groupe → **déplacé** vers ce groupe ;
46+ - si la nouvelle URL **ne matche aucun** groupe → **reste** dans le groupe
47+ actuel.
48+
49+## Multi-fenêtres
50+
51+- Les groupes Firefox sont **par fenêtre**.
52+- Stratégie : **un groupe par fenêtre, créé à la demande** — le groupe d'une
53+ config est instancié dans une fenêtre la première fois qu'un onglet de cette
54+ fenêtre y matche.
55+- Les onglets **ne traversent pas** les fenêtres ; chaque onglet rejoint
56+ l'instance du groupe dans **sa propre** fenêtre.
57+- Quand le **dernier onglet** quitte un groupe géré (fermé ou déplacé), le
58+ **groupe vide est supprimé**. Il sera recréé à la demande au prochain match.
59+
60+## Override manuel
61+
62+- Si l'utilisateur déplace un onglet manuellement (hors du groupe, ou vers un
63+ autre groupe), l'extension **respecte ce choix** et ne re-déplace plus
64+ l'onglet.
65+- Cet override est levé **lorsque l'URL de l'onglet change vers un nouveau
66+ match** (vers un groupe différent du groupe courant).
67+- L'état d'override est stocké en **`storage.session`** : il survit à la mise en
68+ veille de l'event page (MV3), mais **pas** au redémarrage de Firefox. Pas de
69+ re-tri automatique au démarrage.
70+
71+## Choix techniques
72+
73+- **Manifest V3**, background **non-persistant** (event page).
74+- **Vanilla JS / HTML / CSS**, **sans étape de build**.
75+- État volatile (override manuel) en **`storage.session`**.
76+- Distribution : **signée via AMO en "unlisted"** (installation privée durable,
77+ sans publication publique).
78+
79+## Point à confirmer
80+
81+- Condition exacte de **reset de l'override** : interprétée comme « l'override
82+ saute dès que l'URL change et matche un groupe **différent** du groupe
83+ courant » (un re-match vers le *même* groupe ne fait rien). À valider à
84+ l'implémentation.
added _locales/en/messages.json +101 -0
new file mode 100644
@@ -0,0 +1,101 @@
1+{
2+ "extName": { "message": "re:group" },
3+ "extDescription": {
4+ "message": "Automatically sorts tabs into predefined tab groups based on URL regular expressions."
5+ },
6+
7+ "optionsTitle": { "message": "re:group — Settings" },
8+ "optionsSubtitle": {
9+ "message": "Automatically sorts tabs into groups based on URL regular expressions."
10+ },
11+
12+ "groupsLabel": { "message": "Groups" },
13+ "addGroup": { "message": "+ Add a group" },
14+ "save": { "message": "Save" },
15+ "reorganize": { "message": "Reorganize now" },
16+ "unsavedChanges": { "message": "Unsaved changes" },
17+ "exportBtn": { "message": "Export…" },
18+ "importBtn": { "message": "Import…" },
19+
20+ "groupNamePlaceholder": { "message": "Group name" },
21+ "colorLabel": { "message": "Color" },
22+ "moveUp": { "message": "Move up" },
23+ "moveDown": { "message": "Move down" },
24+ "removeGroup": { "message": "Remove group" },
25+ "patternsLabel": { "message": "Regular expressions" },
26+ "addPattern": { "message": "+ Add a regexp" },
27+ "patternPlaceholder": { "message": "e.g. ^https://github\\.com/" },
28+ "patternLabel": { "message": "Regular expression" },
29+ "removePattern": { "message": "Remove" },
30+
31+ "colorBlue": { "message": "Blue" },
32+ "colorCyan": { "message": "Cyan" },
33+ "colorGrey": { "message": "Grey" },
34+ "colorGreen": { "message": "Green" },
35+ "colorOrange": { "message": "Orange" },
36+ "colorPink": { "message": "Pink" },
37+ "colorPurple": { "message": "Purple" },
38+ "colorRed": { "message": "Red" },
39+ "colorYellow": { "message": "Yellow" },
40+
41+ "statusSaved": { "message": "Settings saved." },
42+ "statusReorganizing": { "message": "Reorganizing…" },
43+ "statusReorganized": { "message": "Tabs reorganized." },
44+ "statusReorganizeFailed": {
45+ "message": "Failed: $error$",
46+ "placeholders": { "error": { "content": "$1" } }
47+ },
48+ "statusImportRejected": {
49+ "message": "Import rejected: $reason$",
50+ "placeholders": { "reason": { "content": "$1" } }
51+ },
52+ "statusImported": { "message": "Settings imported and saved." },
53+ "statusImportFailed": {
54+ "message": "Import failed: $error$",
55+ "placeholders": { "error": { "content": "$1" } }
56+ },
57+
58+ "validateNoConfig": { "message": "Configuration missing or invalid." },
59+ "validateGroupsNotList": { "message": "The group list is invalid." },
60+ "validateGroupInvalid": {
61+ "message": "Group $label$ is invalid.",
62+ "placeholders": { "label": { "content": "$1" } }
63+ },
64+ "validateGroupNoId": {
65+ "message": "Group $label$: missing id.",
66+ "placeholders": { "label": { "content": "$1" } }
67+ },
68+ "validateGroupDupId": {
69+ "message": "Group $label$: duplicate id.",
70+ "placeholders": { "label": { "content": "$1" } }
71+ },
72+ "validateGroupNoName": {
73+ "message": "Group $label$: name required.",
74+ "placeholders": { "label": { "content": "$1" } }
75+ },
76+ "validateGroupBadColor": {
77+ "message": "Group $label$: invalid color.",
78+ "placeholders": { "label": { "content": "$1" } }
79+ },
80+ "validateGroupBadPatterns": {
81+ "message": "Group $label$: the regexp list is invalid.",
82+ "placeholders": { "label": { "content": "$1" } }
83+ },
84+ "validateGroupPatternNotString": {
85+ "message": "Group $label$: a regexp is not a string.",
86+ "placeholders": { "label": { "content": "$1" } }
87+ },
88+ "validateGroupBadRegex": {
89+ "message": "Group $label$: invalid regexp “$pattern$” ($detail$).",
90+ "placeholders": {
91+ "label": { "content": "$1" },
92+ "pattern": { "content": "$2" },
93+ "detail": { "content": "$3" }
94+ }
95+ },
96+
97+ "groupFallbackLabel": {
98+ "message": "#$index$",
99+ "placeholders": { "index": { "content": "$1" } }
100+ }
101+}
new file mode 100644
@@ -0,0 +1,101 @@
1+{
2+ "extName": { "message": "re:group" },
3+ "extDescription": {
4+ "message": "Automatically sorts tabs into predefined tab groups based on URL regular expressions."
5+ },
6+
7+ "optionsTitle": { "message": "re:group — Settings" },
8+ "optionsSubtitle": {
9+ "message": "Automatically sorts tabs into groups based on URL regular expressions."
10+ },
11+
12+ "groupsLabel": { "message": "Groups" },
13+ "addGroup": { "message": "+ Add a group" },
14+ "save": { "message": "Save" },
15+ "reorganize": { "message": "Reorganize now" },
16+ "unsavedChanges": { "message": "Unsaved changes" },
17+ "exportBtn": { "message": "Export…" },
18+ "importBtn": { "message": "Import…" },
19+
20+ "groupNamePlaceholder": { "message": "Group name" },
21+ "colorLabel": { "message": "Color" },
22+ "moveUp": { "message": "Move up" },
23+ "moveDown": { "message": "Move down" },
24+ "removeGroup": { "message": "Remove group" },
25+ "patternsLabel": { "message": "Regular expressions" },
26+ "addPattern": { "message": "+ Add a regexp" },
27+ "patternPlaceholder": { "message": "e.g. ^https://github\\.com/" },
28+ "patternLabel": { "message": "Regular expression" },
29+ "removePattern": { "message": "Remove" },
30+
31+ "colorBlue": { "message": "Blue" },
32+ "colorCyan": { "message": "Cyan" },
33+ "colorGrey": { "message": "Grey" },
34+ "colorGreen": { "message": "Green" },
35+ "colorOrange": { "message": "Orange" },
36+ "colorPink": { "message": "Pink" },
37+ "colorPurple": { "message": "Purple" },
38+ "colorRed": { "message": "Red" },
39+ "colorYellow": { "message": "Yellow" },
40+
41+ "statusSaved": { "message": "Settings saved." },
42+ "statusReorganizing": { "message": "Reorganizing…" },
43+ "statusReorganized": { "message": "Tabs reorganized." },
44+ "statusReorganizeFailed": {
45+ "message": "Failed: $error$",
46+ "placeholders": { "error": { "content": "$1" } }
47+ },
48+ "statusImportRejected": {
49+ "message": "Import rejected: $reason$",
50+ "placeholders": { "reason": { "content": "$1" } }
51+ },
52+ "statusImported": { "message": "Settings imported and saved." },
53+ "statusImportFailed": {
54+ "message": "Import failed: $error$",
55+ "placeholders": { "error": { "content": "$1" } }
56+ },
57+
58+ "validateNoConfig": { "message": "Configuration missing or invalid." },
59+ "validateGroupsNotList": { "message": "The group list is invalid." },
60+ "validateGroupInvalid": {
61+ "message": "Group $label$ is invalid.",
62+ "placeholders": { "label": { "content": "$1" } }
63+ },
64+ "validateGroupNoId": {
65+ "message": "Group $label$: missing id.",
66+ "placeholders": { "label": { "content": "$1" } }
67+ },
68+ "validateGroupDupId": {
69+ "message": "Group $label$: duplicate id.",
70+ "placeholders": { "label": { "content": "$1" } }
71+ },
72+ "validateGroupNoName": {
73+ "message": "Group $label$: name required.",
74+ "placeholders": { "label": { "content": "$1" } }
75+ },
76+ "validateGroupBadColor": {
77+ "message": "Group $label$: invalid color.",
78+ "placeholders": { "label": { "content": "$1" } }
79+ },
80+ "validateGroupBadPatterns": {
81+ "message": "Group $label$: the regexp list is invalid.",
82+ "placeholders": { "label": { "content": "$1" } }
83+ },
84+ "validateGroupPatternNotString": {
85+ "message": "Group $label$: a regexp is not a string.",
86+ "placeholders": { "label": { "content": "$1" } }
87+ },
88+ "validateGroupBadRegex": {
89+ "message": "Group $label$: invalid regexp “$pattern$” ($detail$).",
90+ "placeholders": {
91+ "label": { "content": "$1" },
92+ "pattern": { "content": "$2" },
93+ "detail": { "content": "$3" }
94+ }
95+ },
96+
97+ "groupFallbackLabel": {
98+ "message": "#$index$",
99+ "placeholders": { "index": { "content": "$1" } }
100+ }
101+}
added _locales/fr/messages.json +101 -0
new file mode 100644
@@ -0,0 +1,101 @@
1+{
2+ "extName": { "message": "re:group" },
3+ "extDescription": {
4+ "message": "Range automatiquement les onglets dans des groupes prédéfinis selon des expressions régulières sur les URLs."
5+ },
6+
7+ "optionsTitle": { "message": "re:group — Configuration" },
8+ "optionsSubtitle": {
9+ "message": "Range automatiquement les onglets dans des groupes selon des expressions régulières sur les URLs."
10+ },
11+
12+ "groupsLabel": { "message": "Groupes" },
13+ "addGroup": { "message": "+ Ajouter un groupe" },
14+ "save": { "message": "Enregistrer" },
15+ "reorganize": { "message": "Ré-organiser maintenant" },
16+ "unsavedChanges": { "message": "Modifications non enregistrées" },
17+ "exportBtn": { "message": "Exporter…" },
18+ "importBtn": { "message": "Importer…" },
19+
20+ "groupNamePlaceholder": { "message": "Nom du groupe" },
21+ "colorLabel": { "message": "Couleur" },
22+ "moveUp": { "message": "Monter" },
23+ "moveDown": { "message": "Descendre" },
24+ "removeGroup": { "message": "Supprimer le groupe" },
25+ "patternsLabel": { "message": "Expressions régulières" },
26+ "addPattern": { "message": "+ Ajouter une regexp" },
27+ "patternPlaceholder": { "message": "ex : ^https://github\\.com/" },
28+ "patternLabel": { "message": "Expression régulière" },
29+ "removePattern": { "message": "Supprimer" },
30+
31+ "colorBlue": { "message": "Bleu" },
32+ "colorCyan": { "message": "Cyan" },
33+ "colorGrey": { "message": "Gris" },
34+ "colorGreen": { "message": "Vert" },
35+ "colorOrange": { "message": "Orange" },
36+ "colorPink": { "message": "Rose" },
37+ "colorPurple": { "message": "Violet" },
38+ "colorRed": { "message": "Rouge" },
39+ "colorYellow": { "message": "Jaune" },
40+
41+ "statusSaved": { "message": "Configuration enregistrée." },
42+ "statusReorganizing": { "message": "Ré-organisation en cours…" },
43+ "statusReorganized": { "message": "Onglets ré-organisés." },
44+ "statusReorganizeFailed": {
45+ "message": "Échec : $error$",
46+ "placeholders": { "error": { "content": "$1" } }
47+ },
48+ "statusImportRejected": {
49+ "message": "Import refusé : $reason$",
50+ "placeholders": { "reason": { "content": "$1" } }
51+ },
52+ "statusImported": { "message": "Configuration importée et enregistrée." },
53+ "statusImportFailed": {
54+ "message": "Import impossible : $error$",
55+ "placeholders": { "error": { "content": "$1" } }
56+ },
57+
58+ "validateNoConfig": { "message": "Configuration absente ou invalide." },
59+ "validateGroupsNotList": { "message": "La liste des groupes est invalide." },
60+ "validateGroupInvalid": {
61+ "message": "Groupe $label$ invalide.",
62+ "placeholders": { "label": { "content": "$1" } }
63+ },
64+ "validateGroupNoId": {
65+ "message": "Groupe $label$ : id manquant.",
66+ "placeholders": { "label": { "content": "$1" } }
67+ },
68+ "validateGroupDupId": {
69+ "message": "Groupe $label$ : id dupliqué.",
70+ "placeholders": { "label": { "content": "$1" } }
71+ },
72+ "validateGroupNoName": {
73+ "message": "Groupe $label$ : nom requis.",
74+ "placeholders": { "label": { "content": "$1" } }
75+ },
76+ "validateGroupBadColor": {
77+ "message": "Groupe $label$ : couleur invalide.",
78+ "placeholders": { "label": { "content": "$1" } }
79+ },
80+ "validateGroupBadPatterns": {
81+ "message": "Groupe $label$ : la liste de regexp est invalide.",
82+ "placeholders": { "label": { "content": "$1" } }
83+ },
84+ "validateGroupPatternNotString": {
85+ "message": "Groupe $label$ : une regexp n'est pas une chaîne.",
86+ "placeholders": { "label": { "content": "$1" } }
87+ },
88+ "validateGroupBadRegex": {
89+ "message": "Groupe $label$ : regexp invalide « $pattern$ » ($detail$).",
90+ "placeholders": {
91+ "label": { "content": "$1" },
92+ "pattern": { "content": "$2" },
93+ "detail": { "content": "$3" }
94+ }
95+ },
96+
97+ "groupFallbackLabel": {
98+ "message": "n°$index$",
99+ "placeholders": { "index": { "content": "$1" } }
100+ }
101+}
new file mode 100644
@@ -0,0 +1,101 @@
1+{
2+ "extName": { "message": "re:group" },
3+ "extDescription": {
4+ "message": "Range automatiquement les onglets dans des groupes prédéfinis selon des expressions régulières sur les URLs."
5+ },
6+
7+ "optionsTitle": { "message": "re:group — Configuration" },
8+ "optionsSubtitle": {
9+ "message": "Range automatiquement les onglets dans des groupes selon des expressions régulières sur les URLs."
10+ },
11+
12+ "groupsLabel": { "message": "Groupes" },
13+ "addGroup": { "message": "+ Ajouter un groupe" },
14+ "save": { "message": "Enregistrer" },
15+ "reorganize": { "message": "Ré-organiser maintenant" },
16+ "unsavedChanges": { "message": "Modifications non enregistrées" },
17+ "exportBtn": { "message": "Exporter…" },
18+ "importBtn": { "message": "Importer…" },
19+
20+ "groupNamePlaceholder": { "message": "Nom du groupe" },
21+ "colorLabel": { "message": "Couleur" },
22+ "moveUp": { "message": "Monter" },
23+ "moveDown": { "message": "Descendre" },
24+ "removeGroup": { "message": "Supprimer le groupe" },
25+ "patternsLabel": { "message": "Expressions régulières" },
26+ "addPattern": { "message": "+ Ajouter une regexp" },
27+ "patternPlaceholder": { "message": "ex : ^https://github\\.com/" },
28+ "patternLabel": { "message": "Expression régulière" },
29+ "removePattern": { "message": "Supprimer" },
30+
31+ "colorBlue": { "message": "Bleu" },
32+ "colorCyan": { "message": "Cyan" },
33+ "colorGrey": { "message": "Gris" },
34+ "colorGreen": { "message": "Vert" },
35+ "colorOrange": { "message": "Orange" },
36+ "colorPink": { "message": "Rose" },
37+ "colorPurple": { "message": "Violet" },
38+ "colorRed": { "message": "Rouge" },
39+ "colorYellow": { "message": "Jaune" },
40+
41+ "statusSaved": { "message": "Configuration enregistrée." },
42+ "statusReorganizing": { "message": "Ré-organisation en cours…" },
43+ "statusReorganized": { "message": "Onglets ré-organisés." },
44+ "statusReorganizeFailed": {
45+ "message": "Échec : $error$",
46+ "placeholders": { "error": { "content": "$1" } }
47+ },
48+ "statusImportRejected": {
49+ "message": "Import refusé : $reason$",
50+ "placeholders": { "reason": { "content": "$1" } }
51+ },
52+ "statusImported": { "message": "Configuration importée et enregistrée." },
53+ "statusImportFailed": {
54+ "message": "Import impossible : $error$",
55+ "placeholders": { "error": { "content": "$1" } }
56+ },
57+
58+ "validateNoConfig": { "message": "Configuration absente ou invalide." },
59+ "validateGroupsNotList": { "message": "La liste des groupes est invalide." },
60+ "validateGroupInvalid": {
61+ "message": "Groupe $label$ invalide.",
62+ "placeholders": { "label": { "content": "$1" } }
63+ },
64+ "validateGroupNoId": {
65+ "message": "Groupe $label$ : id manquant.",
66+ "placeholders": { "label": { "content": "$1" } }
67+ },
68+ "validateGroupDupId": {
69+ "message": "Groupe $label$ : id dupliqué.",
70+ "placeholders": { "label": { "content": "$1" } }
71+ },
72+ "validateGroupNoName": {
73+ "message": "Groupe $label$ : nom requis.",
74+ "placeholders": { "label": { "content": "$1" } }
75+ },
76+ "validateGroupBadColor": {
77+ "message": "Groupe $label$ : couleur invalide.",
78+ "placeholders": { "label": { "content": "$1" } }
79+ },
80+ "validateGroupBadPatterns": {
81+ "message": "Groupe $label$ : la liste de regexp est invalide.",
82+ "placeholders": { "label": { "content": "$1" } }
83+ },
84+ "validateGroupPatternNotString": {
85+ "message": "Groupe $label$ : une regexp n'est pas une chaîne.",
86+ "placeholders": { "label": { "content": "$1" } }
87+ },
88+ "validateGroupBadRegex": {
89+ "message": "Groupe $label$ : regexp invalide « $pattern$ » ($detail$).",
90+ "placeholders": {
91+ "label": { "content": "$1" },
92+ "pattern": { "content": "$2" },
93+ "detail": { "content": "$3" }
94+ }
95+ },
96+
97+ "groupFallbackLabel": {
98+ "message": "n°$index$",
99+ "placeholders": { "index": { "content": "$1" } }
100+ }
101+}
added background.js +36 -0
new file mode 100644
@@ -0,0 +1,36 @@
1+// Event-page entry point. Wires browser events to the grouping engine.
2+
3+import {
4+ evaluateTab,
5+ handleGroupChange,
6+ handleGroupRemoved,
7+ handleTabRemoved,
8+ reorganizeAll,
9+} from "./src/grouper.js";
10+
11+// React to navigations (incl. SPA url changes) and pin state, and detect manual
12+// group moves. No event filter: `properties` filtering for groupId isn't
13+// portable across versions, so we branch cheaply on changeInfo instead.
14+browser.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
15+ if ("groupId" in changeInfo) {
16+ handleGroupChange(tabId);
17+ }
18+ if ("url" in changeInfo || "pinned" in changeInfo) {
19+ evaluateTab(tab);
20+ }
21+});
22+
23+// Firefox auto-removes empty groups; keep our instance map in sync.
24+if (browser.tabGroups?.onRemoved) {
25+ browser.tabGroups.onRemoved.addListener((group) => handleGroupRemoved(group));
26+}
27+
28+browser.tabs.onRemoved.addListener((tabId) => handleTabRemoved(tabId));
29+
30+// Options-page commands.
31+browser.runtime.onMessage.addListener((message) => {
32+ if (message?.type === "reorganize") {
33+ return reorganizeAll().then(() => ({ ok: true }));
34+ }
35+ return undefined;
36+});
new file mode 100644
@@ -0,0 +1,36 @@
1+// Event-page entry point. Wires browser events to the grouping engine.
2+
3+import {
4+ evaluateTab,
5+ handleGroupChange,
6+ handleGroupRemoved,
7+ handleTabRemoved,
8+ reorganizeAll,
9+} from "./src/grouper.js";
10+
11+// React to navigations (incl. SPA url changes) and pin state, and detect manual
12+// group moves. No event filter: `properties` filtering for groupId isn't
13+// portable across versions, so we branch cheaply on changeInfo instead.
14+browser.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
15+ if ("groupId" in changeInfo) {
16+ handleGroupChange(tabId);
17+ }
18+ if ("url" in changeInfo || "pinned" in changeInfo) {
19+ evaluateTab(tab);
20+ }
21+});
22+
23+// Firefox auto-removes empty groups; keep our instance map in sync.
24+if (browser.tabGroups?.onRemoved) {
25+ browser.tabGroups.onRemoved.addListener((group) => handleGroupRemoved(group));
26+}
27+
28+browser.tabs.onRemoved.addListener((tabId) => handleTabRemoved(tabId));
29+
30+// Options-page commands.
31+browser.runtime.onMessage.addListener((message) => {
32+ if (message?.type === "reorganize") {
33+ return reorganizeAll().then(() => ({ ok: true }));
34+ }
35+ return undefined;
36+});
added icons/icon-48.png +0 -0
new file mode 100644
Binary files /dev/null and b/icons/icon-48.png differ
new file mode 100644
Binary files /dev/null and b/icons/icon-48.png differBinary files /dev/null and b/icons/icon-48.png differ
added icons/icon-96.png +0 -0
new file mode 100644
Binary files /dev/null and b/icons/icon-96.png differ
new file mode 100644
Binary files /dev/null and b/icons/icon-96.png differBinary files /dev/null and b/icons/icon-96.png differ
added manifest.json +29 -0
new file mode 100644
@@ -0,0 +1,29 @@
1+{
2+ "manifest_version": 3,
3+ "name": "__MSG_extName__",
4+ "version": "0.1.0",
5+ "description": "__MSG_extDescription__",
6+ "default_locale": "fr",
7+ "browser_specific_settings": {
8+ "gecko": {
9+ "id": "auto-group@judu",
10+ "strict_min_version": "151.0",
11+ "data_collection_permissions": {
12+ "required": ["none"]
13+ }
14+ }
15+ },
16+ "permissions": ["tabs", "tabGroups", "storage"],
17+ "background": {
18+ "scripts": ["background.js"],
19+ "type": "module"
20+ },
21+ "options_ui": {
22+ "page": "options/options.html",
23+ "open_in_tab": true
24+ },
25+ "icons": {
26+ "48": "icons/icon-48.png",
27+ "96": "icons/icon-96.png"
28+ }
29+}
new file mode 100644
@@ -0,0 +1,29 @@
1+{
2+ "manifest_version": 3,
3+ "name": "__MSG_extName__",
4+ "version": "0.1.0",
5+ "description": "__MSG_extDescription__",
6+ "default_locale": "fr",
7+ "browser_specific_settings": {
8+ "gecko": {
9+ "id": "auto-group@judu",
10+ "strict_min_version": "151.0",
11+ "data_collection_permissions": {
12+ "required": ["none"]
13+ }
14+ }
15+ },
16+ "permissions": ["tabs", "tabGroups", "storage"],
17+ "background": {
18+ "scripts": ["background.js"],
19+ "type": "module"
20+ },
21+ "options_ui": {
22+ "page": "options/options.html",
23+ "open_in_tab": true
24+ },
25+ "icons": {
26+ "48": "icons/icon-48.png",
27+ "96": "icons/icon-96.png"
28+ }
29+}
added options/options.css +206 -0
new file mode 100644
@@ -0,0 +1,206 @@
1+:root {
2+ --bg: #f8f9fa;
3+ --card: #ffffff;
4+ --border: #d7d9dc;
5+ --text: #15141a;
6+ --muted: #5b5b66;
7+ --accent: #0a84ff;
8+ --danger: #d70022;
9+ --error-bg: #fff0f1;
10+ color-scheme: light dark;
11+}
12+
13+@media (prefers-color-scheme: dark) {
14+ :root {
15+ --bg: #1c1b22;
16+ --card: #2b2a33;
17+ --border: #444;
18+ --text: #fbfbfe;
19+ --muted: #b1b1bb;
20+ --error-bg: #3a1d22;
21+ }
22+}
23+
24+* {
25+ box-sizing: border-box;
26+}
27+
28+body {
29+ margin: 0;
30+ padding: 2rem;
31+ max-width: 860px;
32+ margin-inline: auto;
33+ font: 14px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif;
34+ background: var(--bg);
35+ color: var(--text);
36+}
37+
38+h1 {
39+ margin: 0;
40+ font-size: 1.5rem;
41+}
42+
43+.subtitle {
44+ margin: 0.25rem 0 1.5rem;
45+ color: var(--muted);
46+}
47+
48+.group {
49+ background: var(--card);
50+ border: 1px solid var(--border);
51+ border-radius: 10px;
52+ padding: 1rem;
53+ margin-bottom: 1rem;
54+}
55+
56+.group-head {
57+ display: flex;
58+ gap: 0.5rem;
59+ align-items: center;
60+ margin-bottom: 0.75rem;
61+}
62+
63+.group-name {
64+ flex: 1;
65+ min-width: 0;
66+}
67+
68+input[type="text"],
69+select {
70+ padding: 0.45rem 0.6rem;
71+ border: 1px solid var(--border);
72+ border-radius: 6px;
73+ background: var(--bg);
74+ color: var(--text);
75+ font: inherit;
76+}
77+
78+input.invalid {
79+ border-color: var(--danger);
80+ background: var(--error-bg);
81+}
82+
83+.patterns {
84+ display: flex;
85+ flex-direction: column;
86+ gap: 0.4rem;
87+ margin-bottom: 0.5rem;
88+}
89+
90+.pattern-row {
91+ display: grid;
92+ grid-template-columns: 1fr auto;
93+ grid-template-areas: "input remove" "error error";
94+ gap: 0.4rem;
95+ align-items: center;
96+}
97+
98+.pattern-input {
99+ grid-area: input;
100+ font-family: ui-monospace, "SFMono-Regular", Menlo, monospace;
101+}
102+
103+.remove-pattern {
104+ grid-area: remove;
105+}
106+
107+.pattern-error {
108+ grid-area: error;
109+ color: var(--danger);
110+ font-size: 0.82rem;
111+ min-height: 0;
112+}
113+
114+button {
115+ padding: 0.45rem 0.8rem;
116+ border: 1px solid var(--border);
117+ border-radius: 6px;
118+ background: var(--card);
119+ color: var(--text);
120+ font: inherit;
121+ cursor: pointer;
122+}
123+
124+button:hover {
125+ filter: brightness(0.97);
126+}
127+
128+button:disabled {
129+ opacity: 0.5;
130+ cursor: not-allowed;
131+}
132+
133+button.primary {
134+ background: var(--accent);
135+ border-color: var(--accent);
136+ color: #fff;
137+}
138+
139+button.ghost {
140+ background: transparent;
141+}
142+
143+button.danger {
144+ color: var(--danger);
145+ border: none;
146+ background: transparent;
147+ font-size: 1rem;
148+ line-height: 1;
149+ padding: 0.3rem 0.5rem;
150+}
151+
152+.move-up,
153+.move-down,
154+.remove-group {
155+ border: none;
156+ background: transparent;
157+ padding: 0.3rem 0.5rem;
158+}
159+
160+.toolbar {
161+ display: flex;
162+ gap: 0.5rem;
163+ align-items: center;
164+ margin-top: 1.5rem;
165+ flex-wrap: wrap;
166+}
167+
168+.toolbar .spacer {
169+ flex: 1;
170+}
171+
172+.dirty-indicator {
173+ display: inline-flex;
174+ align-items: center;
175+ gap: 0.4rem;
176+ color: var(--muted);
177+ font-size: 0.85rem;
178+}
179+
180+.dirty-indicator[hidden] {
181+ display: none;
182+}
183+
184+.dirty-indicator .dot {
185+ width: 0.55rem;
186+ height: 0.55rem;
187+ border-radius: 50%;
188+ background: var(--accent);
189+}
190+
191+.status {
192+ margin-top: 1rem;
193+ min-height: 1.25rem;
194+}
195+
196+.status.ok {
197+ color: #008a22;
198+}
199+
200+.status.error {
201+ color: var(--danger);
202+}
203+
204+#add-group {
205+ width: 100%;
206+}
new file mode 100644
@@ -0,0 +1,206 @@
1+:root {
2+ --bg: #f8f9fa;
3+ --card: #ffffff;
4+ --border: #d7d9dc;
5+ --text: #15141a;
6+ --muted: #5b5b66;
7+ --accent: #0a84ff;
8+ --danger: #d70022;
9+ --error-bg: #fff0f1;
10+ color-scheme: light dark;
11+}
12+
13+@media (prefers-color-scheme: dark) {
14+ :root {
15+ --bg: #1c1b22;
16+ --card: #2b2a33;
17+ --border: #444;
18+ --text: #fbfbfe;
19+ --muted: #b1b1bb;
20+ --error-bg: #3a1d22;
21+ }
22+}
23+
24+* {
25+ box-sizing: border-box;
26+}
27+
28+body {
29+ margin: 0;
30+ padding: 2rem;
31+ max-width: 860px;
32+ margin-inline: auto;
33+ font: 14px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif;
34+ background: var(--bg);
35+ color: var(--text);
36+}
37+
38+h1 {
39+ margin: 0;
40+ font-size: 1.5rem;
41+}
42+
43+.subtitle {
44+ margin: 0.25rem 0 1.5rem;
45+ color: var(--muted);
46+}
47+
48+.group {
49+ background: var(--card);
50+ border: 1px solid var(--border);
51+ border-radius: 10px;
52+ padding: 1rem;
53+ margin-bottom: 1rem;
54+}
55+
56+.group-head {
57+ display: flex;
58+ gap: 0.5rem;
59+ align-items: center;
60+ margin-bottom: 0.75rem;
61+}
62+
63+.group-name {
64+ flex: 1;
65+ min-width: 0;
66+}
67+
68+input[type="text"],
69+select {
70+ padding: 0.45rem 0.6rem;
71+ border: 1px solid var(--border);
72+ border-radius: 6px;
73+ background: var(--bg);
74+ color: var(--text);
75+ font: inherit;
76+}
77+
78+input.invalid {
79+ border-color: var(--danger);
80+ background: var(--error-bg);
81+}
82+
83+.patterns {
84+ display: flex;
85+ flex-direction: column;
86+ gap: 0.4rem;
87+ margin-bottom: 0.5rem;
88+}
89+
90+.pattern-row {
91+ display: grid;
92+ grid-template-columns: 1fr auto;
93+ grid-template-areas: "input remove" "error error";
94+ gap: 0.4rem;
95+ align-items: center;
96+}
97+
98+.pattern-input {
99+ grid-area: input;
100+ font-family: ui-monospace, "SFMono-Regular", Menlo, monospace;
101+}
102+
103+.remove-pattern {
104+ grid-area: remove;
105+}
106+
107+.pattern-error {
108+ grid-area: error;
109+ color: var(--danger);
110+ font-size: 0.82rem;
111+ min-height: 0;
112+}
113+
114+button {
115+ padding: 0.45rem 0.8rem;
116+ border: 1px solid var(--border);
117+ border-radius: 6px;
118+ background: var(--card);
119+ color: var(--text);
120+ font: inherit;
121+ cursor: pointer;
122+}
123+
124+button:hover {
125+ filter: brightness(0.97);
126+}
127+
128+button:disabled {
129+ opacity: 0.5;
130+ cursor: not-allowed;
131+}
132+
133+button.primary {
134+ background: var(--accent);
135+ border-color: var(--accent);
136+ color: #fff;
137+}
138+
139+button.ghost {
140+ background: transparent;
141+}
142+
143+button.danger {
144+ color: var(--danger);
145+ border: none;
146+ background: transparent;
147+ font-size: 1rem;
148+ line-height: 1;
149+ padding: 0.3rem 0.5rem;
150+}
151+
152+.move-up,
153+.move-down,
154+.remove-group {
155+ border: none;
156+ background: transparent;
157+ padding: 0.3rem 0.5rem;
158+}
159+
160+.toolbar {
161+ display: flex;
162+ gap: 0.5rem;
163+ align-items: center;
164+ margin-top: 1.5rem;
165+ flex-wrap: wrap;
166+}
167+
168+.toolbar .spacer {
169+ flex: 1;
170+}
171+
172+.dirty-indicator {
173+ display: inline-flex;
174+ align-items: center;
175+ gap: 0.4rem;
176+ color: var(--muted);
177+ font-size: 0.85rem;
178+}
179+
180+.dirty-indicator[hidden] {
181+ display: none;
182+}
183+
184+.dirty-indicator .dot {
185+ width: 0.55rem;
186+ height: 0.55rem;
187+ border-radius: 50%;
188+ background: var(--accent);
189+}
190+
191+.status {
192+ margin-top: 1rem;
193+ min-height: 1.25rem;
194+}
195+
196+.status.ok {
197+ color: #008a22;
198+}
199+
200+.status.error {
201+ color: var(--danger);
202+}
203+
204+#add-group {
205+ width: 100%;
206+}
added options/options.html +82 -0
new file mode 100644
@@ -0,0 +1,82 @@
1+<!DOCTYPE html>
2+<html lang="fr">
3+ <head>
4+ <meta charset="utf-8" />
5+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6+ <title data-i18n="optionsTitle">re:group — Configuration</title>
7+ <link rel="icon" type="image/png" href="../icons/icon-48.png" />
8+ <link rel="stylesheet" href="options.css" />
9+ </head>
10+ <body>
11+ <header>
12+ <h1 data-i18n="extName">re:group</h1>
13+ <p class="subtitle" data-i18n="optionsSubtitle">
14+ Range automatiquement les onglets dans des groupes selon des expressions
15+ régulières sur les URLs.
16+ </p>
17+ </header>
18+
19+ <main>
20+ <section id="groups" data-i18n-aria-label="groupsLabel" aria-label="Groupes"></section>
21+
22+ <button id="add-group" type="button" class="secondary" data-i18n="addGroup">
23+ + Ajouter un groupe
24+ </button>
25+
26+ <div class="toolbar">
27+ <button id="save" type="button" class="primary" data-i18n="save">Enregistrer</button>
28+ <button id="reorganize" type="button" data-i18n="reorganize">Ré-organiser maintenant</button>
29+ <span id="dirty-indicator" class="dirty-indicator" hidden>
30+ <span class="dot" aria-hidden="true"></span><span data-i18n="unsavedChanges">Modifications non enregistrées</span>
31+ </span>
32+ <span class="spacer"></span>
33+ <button id="export" type="button" class="ghost" data-i18n="exportBtn">Exporter…</button>
34+ <button id="import" type="button" class="ghost" data-i18n="importBtn">Importer…</button>
35+ <input id="import-file" type="file" accept="application/json" hidden />
36+ </div>
37+
38+ <p id="status" class="status" role="status" aria-live="polite"></p>
39+ </main>
40+
41+ <!-- Template for one group card -->
42+ <template id="group-template">
43+ <article class="group">
44+ <div class="group-head">
45+ <input
46+ class="group-name"
47+ type="text"
48+ placeholder="Nom du groupe"
49+ aria-label="Nom du groupe"
50+ data-i18n-placeholder="groupNamePlaceholder"
51+ data-i18n-aria-label="groupNamePlaceholder"
52+ />
53+ <select class="group-color" aria-label="Couleur" data-i18n-aria-label="colorLabel"></select>
54+ <button class="move-up" type="button" title="Monter" aria-label="Monter" data-i18n-title="moveUp" data-i18n-aria-label="moveUp">↑</button>
55+ <button class="move-down" type="button" title="Descendre" aria-label="Descendre" data-i18n-title="moveDown" data-i18n-aria-label="moveDown">↓</button>
56+ <button class="remove-group danger" type="button" title="Supprimer le groupe" data-i18n-title="removeGroup">✕</button>
57+ </div>
58+ <div class="patterns" aria-label="Expressions régulières" data-i18n-aria-label="patternsLabel"></div>
59+ <button class="add-pattern ghost" type="button" data-i18n="addPattern">+ Ajouter une regexp</button>
60+ </article>
61+ </template>
62+
63+ <!-- Template for one regexp row -->
64+ <template id="pattern-template">
65+ <div class="pattern-row">
66+ <input
67+ class="pattern-input"
68+ type="text"
69+ spellcheck="false"
70+ placeholder="ex : ^https://github\.com/"
71+ aria-label="Expression régulière"
72+ data-i18n-placeholder="patternPlaceholder"
73+ data-i18n-aria-label="patternLabel"
74+ />
75+ <button class="remove-pattern danger" type="button" title="Supprimer" data-i18n-title="removePattern">✕</button>
76+ <span class="pattern-error"></span>
77+ </div>
78+ </template>
79+
80+ <script type="module" src="options.js"></script>
81+ </body>
82+</html>
new file mode 100644
@@ -0,0 +1,82 @@
1+<!DOCTYPE html>
2+<html lang="fr">
3+ <head>
4+ <meta charset="utf-8" />
5+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6+ <title data-i18n="optionsTitle">re:group — Configuration</title>
7+ <link rel="icon" type="image/png" href="../icons/icon-48.png" />
8+ <link rel="stylesheet" href="options.css" />
9+ </head>
10+ <body>
11+ <header>
12+ <h1 data-i18n="extName">re:group</h1>
13+ <p class="subtitle" data-i18n="optionsSubtitle">
14+ Range automatiquement les onglets dans des groupes selon des expressions
15+ régulières sur les URLs.
16+ </p>
17+ </header>
18+
19+ <main>
20+ <section id="groups" data-i18n-aria-label="groupsLabel" aria-label="Groupes"></section>
21+
22+ <button id="add-group" type="button" class="secondary" data-i18n="addGroup">
23+ + Ajouter un groupe
24+ </button>
25+
26+ <div class="toolbar">
27+ <button id="save" type="button" class="primary" data-i18n="save">Enregistrer</button>
28+ <button id="reorganize" type="button" data-i18n="reorganize">Ré-organiser maintenant</button>
29+ <span id="dirty-indicator" class="dirty-indicator" hidden>
30+ <span class="dot" aria-hidden="true"></span><span data-i18n="unsavedChanges">Modifications non enregistrées</span>
31+ </span>
32+ <span class="spacer"></span>
33+ <button id="export" type="button" class="ghost" data-i18n="exportBtn">Exporter…</button>
34+ <button id="import" type="button" class="ghost" data-i18n="importBtn">Importer…</button>
35+ <input id="import-file" type="file" accept="application/json" hidden />
36+ </div>
37+
38+ <p id="status" class="status" role="status" aria-live="polite"></p>
39+ </main>
40+
41+ <!-- Template for one group card -->
42+ <template id="group-template">
43+ <article class="group">
44+ <div class="group-head">
45+ <input
46+ class="group-name"
47+ type="text"
48+ placeholder="Nom du groupe"
49+ aria-label="Nom du groupe"
50+ data-i18n-placeholder="groupNamePlaceholder"
51+ data-i18n-aria-label="groupNamePlaceholder"
52+ />
53+ <select class="group-color" aria-label="Couleur" data-i18n-aria-label="colorLabel"></select>
54+ <button class="move-up" type="button" title="Monter" aria-label="Monter" data-i18n-title="moveUp" data-i18n-aria-label="moveUp">↑</button>
55+ <button class="move-down" type="button" title="Descendre" aria-label="Descendre" data-i18n-title="moveDown" data-i18n-aria-label="moveDown">↓</button>
56+ <button class="remove-group danger" type="button" title="Supprimer le groupe" data-i18n-title="removeGroup">✕</button>
57+ </div>
58+ <div class="patterns" aria-label="Expressions régulières" data-i18n-aria-label="patternsLabel"></div>
59+ <button class="add-pattern ghost" type="button" data-i18n="addPattern">+ Ajouter une regexp</button>
60+ </article>
61+ </template>
62+
63+ <!-- Template for one regexp row -->
64+ <template id="pattern-template">
65+ <div class="pattern-row">
66+ <input
67+ class="pattern-input"
68+ type="text"
69+ spellcheck="false"
70+ placeholder="ex : ^https://github\.com/"
71+ aria-label="Expression régulière"
72+ data-i18n-placeholder="patternPlaceholder"
73+ data-i18n-aria-label="patternLabel"
74+ />
75+ <button class="remove-pattern danger" type="button" title="Supprimer" data-i18n-title="removePattern">✕</button>
76+ <span class="pattern-error"></span>
77+ </div>
78+ </template>
79+
80+ <script type="module" src="options.js"></script>
81+ </body>
82+</html>
added options/options.js +246 -0
new file mode 100644
@@ -0,0 +1,246 @@
1+import {
2+ COLORS,
3+ CONFIG_VERSION,
4+ loadConfig,
5+ saveConfig,
6+ validateConfig,
7+ newGroupId,
8+} from "../src/config.js";
9+import { applyI18n, t } from "../src/i18n.js";
10+
11+const groupsEl = document.getElementById("groups");
12+const statusEl = document.getElementById("status");
13+const saveBtn = document.getElementById("save");
14+const reorganizeBtn = document.getElementById("reorganize");
15+const dirtyIndicator = document.getElementById("dirty-indicator");
16+const groupTpl = document.getElementById("group-template");
17+const patternTpl = document.getElementById("pattern-template");
18+
19+// --- rendering ------------------------------------------------------------
20+
21+function colorLabel(color) {
22+ return t("color" + color[0].toUpperCase() + color.slice(1)) || color;
23+}
24+
25+function makeColorSelect(selectEl, selected) {
26+ for (const color of COLORS) {
27+ const opt = document.createElement("option");
28+ opt.value = color;
29+ opt.textContent = colorLabel(color);
30+ if (color === selected) opt.selected = true;
31+ selectEl.append(opt);
32+ }
33+}
34+
35+function addPatternRow(patternsEl, value = "") {
36+ const row = patternTpl.content.firstElementChild.cloneNode(true);
37+ const input = row.querySelector(".pattern-input");
38+ input.value = value;
39+ input.addEventListener("input", () => {
40+ validatePatternRow(row);
41+ refreshSaveState();
42+ });
43+ row.querySelector(".remove-pattern").addEventListener("click", () => {
44+ row.remove();
45+ setDirty(true);
46+ refreshSaveState();
47+ });
48+ applyI18n(row);
49+ patternsEl.append(row);
50+ validatePatternRow(row);
51+}
52+
53+function renderGroup(group) {
54+ const card = groupTpl.content.firstElementChild.cloneNode(true);
55+ card.dataset.id = group.id || newGroupId();
56+ card.querySelector(".group-name").value = group.name || "";
57+ makeColorSelect(card.querySelector(".group-color"), group.color || COLORS[0]);
58+
59+ const patternsEl = card.querySelector(".patterns");
60+ const patterns = group.patterns?.length ? group.patterns : [""];
61+ for (const p of patterns) addPatternRow(patternsEl, p);
62+
63+ card.querySelector(".group-name").addEventListener("input", refreshSaveState);
64+ card.querySelector(".add-pattern").addEventListener("click", () => {
65+ addPatternRow(patternsEl);
66+ setDirty(true);
67+ });
68+ card.querySelector(".remove-group").addEventListener("click", () => {
69+ card.remove();
70+ setDirty(true);
71+ refreshSaveState();
72+ });
73+ card.querySelector(".move-up").addEventListener("click", () => {
74+ const prev = card.previousElementSibling;
75+ if (prev) {
76+ groupsEl.insertBefore(card, prev);
77+ setDirty(true);
78+ }
79+ });
80+ card.querySelector(".move-down").addEventListener("click", () => {
81+ const next = card.nextElementSibling;
82+ if (next) {
83+ groupsEl.insertBefore(next, card);
84+ setDirty(true);
85+ }
86+ });
87+
88+ applyI18n(card);
89+ groupsEl.append(card);
90+}
91+
92+function render(config) {
93+ groupsEl.replaceChildren();
94+ for (const group of config.groups) renderGroup(group);
95+ refreshSaveState();
96+}
97+
98+// --- validation -----------------------------------------------------------
99+
100+/** Validates a single regexp row; returns true if valid (or empty). */
101+function validatePatternRow(row) {
102+ const input = row.querySelector(".pattern-input");
103+ const errorEl = row.querySelector(".pattern-error");
104+ const value = input.value;
105+ if (value === "") {
106+ input.classList.remove("invalid");
107+ errorEl.textContent = "";
108+ return true;
109+ }
110+ try {
111+ new RegExp(value, "i");
112+ input.classList.remove("invalid");
113+ errorEl.textContent = "";
114+ return true;
115+ } catch (e) {
116+ input.classList.add("invalid");
117+ errorEl.textContent = e.message;
118+ return false;
119+ }
120+}
121+
122+/** A config is saveable when every regexp is valid and every group is named. */
123+function isFormValid() {
124+ let valid = true;
125+ for (const card of groupsEl.querySelectorAll(".group")) {
126+ if (card.querySelector(".group-name").value.trim() === "") valid = false;
127+ for (const row of card.querySelectorAll(".pattern-row")) {
128+ if (!validatePatternRow(row)) valid = false;
129+ }
130+ }
131+ return valid;
132+}
133+
134+function refreshSaveState() {
135+ saveBtn.disabled = !isFormValid();
136+}
137+
138+// Reorganize uses the config persisted on disk, so it must be disabled while the
139+// form holds unsaved edits, and re-enabled once we save (or import, which saves).
140+function setDirty(dirty) {
141+ reorganizeBtn.disabled = dirty;
142+ dirtyIndicator.hidden = !dirty;
143+}
144+
145+// --- collect + persist ----------------------------------------------------
146+
147+function collectConfig() {
148+ const groups = [...groupsEl.querySelectorAll(".group")].map((card) => ({
149+ id: card.dataset.id,
150+ name: card.querySelector(".group-name").value.trim(),
151+ color: card.querySelector(".group-color").value,
152+ patterns: [...card.querySelectorAll(".pattern-input")]
153+ .map((i) => i.value)
154+ .filter((v) => v !== ""),
155+ }));
156+ return { version: CONFIG_VERSION, groups };
157+}
158+
159+function setStatus(message, kind = "") {
160+ statusEl.textContent = message;
161+ statusEl.className = `status ${kind}`.trim();
162+}
163+
164+async function save() {
165+ try {
166+ await saveConfig(collectConfig());
167+ setDirty(false);
168+ setStatus(t("statusSaved"), "ok");
169+ } catch (e) {
170+ setStatus(e.message, "error");
171+ }
172+}
173+
174+// --- export / import ------------------------------------------------------
175+
176+function exportConfig() {
177+ const json = JSON.stringify(collectConfig(), null, 2);
178+ const blob = new Blob([json], { type: "application/json" });
179+ const url = URL.createObjectURL(blob);
180+ const a = document.createElement("a");
181+ a.href = url;
182+ a.download = "auto-group-config.json";
183+ a.click();
184+ URL.revokeObjectURL(url);
185+}
186+
187+async function importConfig(file) {
188+ try {
189+ const text = await file.text();
190+ const parsed = JSON.parse(text);
191+ const result = validateConfig(parsed);
192+ if (!result.ok) {
193+ setStatus(t("statusImportRejected", result.error), "error");
194+ return;
195+ }
196+ await saveConfig(parsed);
197+ render(parsed);
198+ setDirty(false);
199+ setStatus(t("statusImported"), "ok");
200+ } catch (e) {
201+ setStatus(t("statusImportFailed", e.message), "error");
202+ }
203+}
204+
205+// --- wiring ---------------------------------------------------------------
206+
207+// Typing in any field or changing a colour marks the form dirty. Programmatic
208+// value assignments (during render/import) don't fire these events, so this is
209+// safe.
210+groupsEl.addEventListener("input", () => setDirty(true));
211+groupsEl.addEventListener("change", () => setDirty(true));
212+
213+document.getElementById("add-group").addEventListener("click", () => {
214+ renderGroup({ id: newGroupId(), name: "", color: COLORS[0], patterns: [""] });
215+ setDirty(true);
216+ refreshSaveState();
217+});
218+
219+saveBtn.addEventListener("click", save);
220+
221+document.getElementById("reorganize").addEventListener("click", async () => {
222+ setStatus(t("statusReorganizing"));
223+ try {
224+ await browser.runtime.sendMessage({ type: "reorganize" });
225+ setStatus(t("statusReorganized"), "ok");
226+ } catch (e) {
227+ setStatus(t("statusReorganizeFailed", e.message), "error");
228+ }
229+});
230+
231+document.getElementById("export").addEventListener("click", exportConfig);
232+
233+const importFile = document.getElementById("import-file");
234+document.getElementById("import").addEventListener("click", () => importFile.click());
235+importFile.addEventListener("change", () => {
236+ if (importFile.files[0]) importConfig(importFile.files[0]);
237+ importFile.value = "";
238+});
239+
240+document.documentElement.lang = browser.i18n.getUILanguage();
241+applyI18n(document);
242+
243+loadConfig().then((config) => {
244+ render(config);
245+ setDirty(false);
246+});
new file mode 100644
@@ -0,0 +1,246 @@
1+import {
2+ COLORS,
3+ CONFIG_VERSION,
4+ loadConfig,
5+ saveConfig,
6+ validateConfig,
7+ newGroupId,
8+} from "../src/config.js";
9+import { applyI18n, t } from "../src/i18n.js";
10+
11+const groupsEl = document.getElementById("groups");
12+const statusEl = document.getElementById("status");
13+const saveBtn = document.getElementById("save");
14+const reorganizeBtn = document.getElementById("reorganize");
15+const dirtyIndicator = document.getElementById("dirty-indicator");
16+const groupTpl = document.getElementById("group-template");
17+const patternTpl = document.getElementById("pattern-template");
18+
19+// --- rendering ------------------------------------------------------------
20+
21+function colorLabel(color) {
22+ return t("color" + color[0].toUpperCase() + color.slice(1)) || color;
23+}
24+
25+function makeColorSelect(selectEl, selected) {
26+ for (const color of COLORS) {
27+ const opt = document.createElement("option");
28+ opt.value = color;
29+ opt.textContent = colorLabel(color);
30+ if (color === selected) opt.selected = true;
31+ selectEl.append(opt);
32+ }
33+}
34+
35+function addPatternRow(patternsEl, value = "") {
36+ const row = patternTpl.content.firstElementChild.cloneNode(true);
37+ const input = row.querySelector(".pattern-input");
38+ input.value = value;
39+ input.addEventListener("input", () => {
40+ validatePatternRow(row);
41+ refreshSaveState();
42+ });
43+ row.querySelector(".remove-pattern").addEventListener("click", () => {
44+ row.remove();
45+ setDirty(true);
46+ refreshSaveState();
47+ });
48+ applyI18n(row);
49+ patternsEl.append(row);
50+ validatePatternRow(row);
51+}
52+
53+function renderGroup(group) {
54+ const card = groupTpl.content.firstElementChild.cloneNode(true);
55+ card.dataset.id = group.id || newGroupId();
56+ card.querySelector(".group-name").value = group.name || "";
57+ makeColorSelect(card.querySelector(".group-color"), group.color || COLORS[0]);
58+
59+ const patternsEl = card.querySelector(".patterns");
60+ const patterns = group.patterns?.length ? group.patterns : [""];
61+ for (const p of patterns) addPatternRow(patternsEl, p);
62+
63+ card.querySelector(".group-name").addEventListener("input", refreshSaveState);
64+ card.querySelector(".add-pattern").addEventListener("click", () => {
65+ addPatternRow(patternsEl);
66+ setDirty(true);
67+ });
68+ card.querySelector(".remove-group").addEventListener("click", () => {
69+ card.remove();
70+ setDirty(true);
71+ refreshSaveState();
72+ });
73+ card.querySelector(".move-up").addEventListener("click", () => {
74+ const prev = card.previousElementSibling;
75+ if (prev) {
76+ groupsEl.insertBefore(card, prev);
77+ setDirty(true);
78+ }
79+ });
80+ card.querySelector(".move-down").addEventListener("click", () => {
81+ const next = card.nextElementSibling;
82+ if (next) {
83+ groupsEl.insertBefore(next, card);
84+ setDirty(true);
85+ }
86+ });
87+
88+ applyI18n(card);
89+ groupsEl.append(card);
90+}
91+
92+function render(config) {
93+ groupsEl.replaceChildren();
94+ for (const group of config.groups) renderGroup(group);
95+ refreshSaveState();
96+}
97+
98+// --- validation -----------------------------------------------------------
99+
100+/** Validates a single regexp row; returns true if valid (or empty). */
101+function validatePatternRow(row) {
102+ const input = row.querySelector(".pattern-input");
103+ const errorEl = row.querySelector(".pattern-error");
104+ const value = input.value;
105+ if (value === "") {
106+ input.classList.remove("invalid");
107+ errorEl.textContent = "";
108+ return true;
109+ }
110+ try {
111+ new RegExp(value, "i");
112+ input.classList.remove("invalid");
113+ errorEl.textContent = "";
114+ return true;
115+ } catch (e) {
116+ input.classList.add("invalid");
117+ errorEl.textContent = e.message;
118+ return false;
119+ }
120+}
121+
122+/** A config is saveable when every regexp is valid and every group is named. */
123+function isFormValid() {
124+ let valid = true;
125+ for (const card of groupsEl.querySelectorAll(".group")) {
126+ if (card.querySelector(".group-name").value.trim() === "") valid = false;
127+ for (const row of card.querySelectorAll(".pattern-row")) {
128+ if (!validatePatternRow(row)) valid = false;
129+ }
130+ }
131+ return valid;
132+}
133+
134+function refreshSaveState() {
135+ saveBtn.disabled = !isFormValid();
136+}
137+
138+// Reorganize uses the config persisted on disk, so it must be disabled while the
139+// form holds unsaved edits, and re-enabled once we save (or import, which saves).
140+function setDirty(dirty) {
141+ reorganizeBtn.disabled = dirty;
142+ dirtyIndicator.hidden = !dirty;
143+}
144+
145+// --- collect + persist ----------------------------------------------------
146+
147+function collectConfig() {
148+ const groups = [...groupsEl.querySelectorAll(".group")].map((card) => ({
149+ id: card.dataset.id,
150+ name: card.querySelector(".group-name").value.trim(),
151+ color: card.querySelector(".group-color").value,
152+ patterns: [...card.querySelectorAll(".pattern-input")]
153+ .map((i) => i.value)
154+ .filter((v) => v !== ""),
155+ }));
156+ return { version: CONFIG_VERSION, groups };
157+}
158+
159+function setStatus(message, kind = "") {
160+ statusEl.textContent = message;
161+ statusEl.className = `status ${kind}`.trim();
162+}
163+
164+async function save() {
165+ try {
166+ await saveConfig(collectConfig());
167+ setDirty(false);
168+ setStatus(t("statusSaved"), "ok");
169+ } catch (e) {
170+ setStatus(e.message, "error");
171+ }
172+}
173+
174+// --- export / import ------------------------------------------------------
175+
176+function exportConfig() {
177+ const json = JSON.stringify(collectConfig(), null, 2);
178+ const blob = new Blob([json], { type: "application/json" });
179+ const url = URL.createObjectURL(blob);
180+ const a = document.createElement("a");
181+ a.href = url;
182+ a.download = "auto-group-config.json";
183+ a.click();
184+ URL.revokeObjectURL(url);
185+}
186+
187+async function importConfig(file) {
188+ try {
189+ const text = await file.text();
190+ const parsed = JSON.parse(text);
191+ const result = validateConfig(parsed);
192+ if (!result.ok) {
193+ setStatus(t("statusImportRejected", result.error), "error");
194+ return;
195+ }
196+ await saveConfig(parsed);
197+ render(parsed);
198+ setDirty(false);
199+ setStatus(t("statusImported"), "ok");
200+ } catch (e) {
201+ setStatus(t("statusImportFailed", e.message), "error");
202+ }
203+}
204+
205+// --- wiring ---------------------------------------------------------------
206+
207+// Typing in any field or changing a colour marks the form dirty. Programmatic
208+// value assignments (during render/import) don't fire these events, so this is
209+// safe.
210+groupsEl.addEventListener("input", () => setDirty(true));
211+groupsEl.addEventListener("change", () => setDirty(true));
212+
213+document.getElementById("add-group").addEventListener("click", () => {
214+ renderGroup({ id: newGroupId(), name: "", color: COLORS[0], patterns: [""] });
215+ setDirty(true);
216+ refreshSaveState();
217+});
218+
219+saveBtn.addEventListener("click", save);
220+
221+document.getElementById("reorganize").addEventListener("click", async () => {
222+ setStatus(t("statusReorganizing"));
223+ try {
224+ await browser.runtime.sendMessage({ type: "reorganize" });
225+ setStatus(t("statusReorganized"), "ok");
226+ } catch (e) {
227+ setStatus(t("statusReorganizeFailed", e.message), "error");
228+ }
229+});
230+
231+document.getElementById("export").addEventListener("click", exportConfig);
232+
233+const importFile = document.getElementById("import-file");
234+document.getElementById("import").addEventListener("click", () => importFile.click());
235+importFile.addEventListener("change", () => {
236+ if (importFile.files[0]) importConfig(importFile.files[0]);
237+ importFile.value = "";
238+});
239+
240+document.documentElement.lang = browser.i18n.getUILanguage();
241+applyI18n(document);
242+
243+loadConfig().then((config) => {
244+ render(config);
245+ setDirty(false);
246+});
added src/config.js +119 -0
new file mode 100644
@@ -0,0 +1,119 @@
1+// Configuration model + persistence in storage.local.
2+//
3+// Shape:
4+// { version: 1, groups: [ { id, name, color, patterns: string[] } ] }
5+
6+import { t } from "./i18n.js";
7+
8+export const CONFIG_VERSION = 1;
9+const STORAGE_KEY = "config";
10+
11+/** Firefox tab-group colors (tabGroups.Color). */
12+export const COLORS = [
13+ "blue",
14+ "cyan",
15+ "grey",
16+ "green",
17+ "orange",
18+ "pink",
19+ "purple",
20+ "red",
21+ "yellow",
22+];
23+
24+/** @returns {{version: number, groups: []}} an empty, valid config. */
25+export function emptyConfig() {
26+ return { version: CONFIG_VERSION, groups: [] };
27+}
28+
29+/** Generates a stable id for a new config group. */
30+export function newGroupId() {
31+ return crypto.randomUUID();
32+}
33+
34+/**
35+ * Validates a config object. Returns { ok: true } or { ok: false, error }.
36+ * Used both by the options page (before save) and on import.
37+ * @param {unknown} config
38+ */
39+export function validateConfig(config) {
40+ if (!config || typeof config !== "object") {
41+ return { ok: false, error: t("validateNoConfig") };
42+ }
43+ if (!Array.isArray(config.groups)) {
44+ return { ok: false, error: t("validateGroupsNotList") };
45+ }
46+ const seenIds = new Set();
47+ for (const [i, group] of config.groups.entries()) {
48+ const label = group?.name ? `« ${group.name} »` : t("groupFallbackLabel", String(i + 1));
49+ if (!group || typeof group !== "object") {
50+ return { ok: false, error: t("validateGroupInvalid", label) };
51+ }
52+ if (typeof group.id !== "string" || group.id === "") {
53+ return { ok: false, error: t("validateGroupNoId", label) };
54+ }
55+ if (seenIds.has(group.id)) {
56+ return { ok: false, error: t("validateGroupDupId", label) };
57+ }
58+ seenIds.add(group.id);
59+ if (typeof group.name !== "string" || group.name.trim() === "") {
60+ return { ok: false, error: t("validateGroupNoName", label) };
61+ }
62+ if (!COLORS.includes(group.color)) {
63+ return { ok: false, error: t("validateGroupBadColor", label) };
64+ }
65+ if (!Array.isArray(group.patterns)) {
66+ return { ok: false, error: t("validateGroupBadPatterns", label) };
67+ }
68+ for (const pattern of group.patterns) {
69+ if (typeof pattern !== "string") {
70+ return { ok: false, error: t("validateGroupPatternNotString", label) };
71+ }
72+ try {
73+ new RegExp(pattern, "i");
74+ } catch (e) {
75+ return {
76+ ok: false,
77+ error: t("validateGroupBadRegex", [label, pattern, e.message]),
78+ };
79+ }
80+ }
81+ }
82+ return { ok: true };
83+}
84+
85+/**
86+ * Loads config from storage.local, falling back to an empty config.
87+ * @returns {Promise<{version: number, groups: []}>}
88+ */
89+export async function loadConfig() {
90+ const stored = await browser.storage.local.get(STORAGE_KEY);
91+ const config = stored[STORAGE_KEY];
92+ if (validateConfig(config).ok) return config;
93+ return emptyConfig();
94+}
95+
96+/**
97+ * Persists a config after validating it. Throws on invalid config.
98+ * @param {object} config
99+ */
100+export async function saveConfig(config) {
101+ const result = validateConfig(config);
102+ if (!result.ok) throw new Error(result.error);
103+ await browser.storage.local.set({ [STORAGE_KEY]: config });
104+}
105+
106+/**
107+ * Subscribes to config changes. Calls `callback(newConfig)` whenever the stored
108+ * config changes. Returns an unsubscribe function.
109+ * @param {(config: object) => void} callback
110+ */
111+export function onConfigChanged(callback) {
112+ const listener = (changes, area) => {
113+ if (area === "local" && changes[STORAGE_KEY]) {
114+ callback(changes[STORAGE_KEY].newValue ?? emptyConfig());
115+ }
116+ };
117+ browser.storage.onChanged.addListener(listener);
118+ return () => browser.storage.onChanged.removeListener(listener);
119+}
new file mode 100644
@@ -0,0 +1,119 @@
1+// Configuration model + persistence in storage.local.
2+//
3+// Shape:
4+// { version: 1, groups: [ { id, name, color, patterns: string[] } ] }
5+
6+import { t } from "./i18n.js";
7+
8+export const CONFIG_VERSION = 1;
9+const STORAGE_KEY = "config";
10+
11+/** Firefox tab-group colors (tabGroups.Color). */
12+export const COLORS = [
13+ "blue",
14+ "cyan",
15+ "grey",
16+ "green",
17+ "orange",
18+ "pink",
19+ "purple",
20+ "red",
21+ "yellow",
22+];
23+
24+/** @returns {{version: number, groups: []}} an empty, valid config. */
25+export function emptyConfig() {
26+ return { version: CONFIG_VERSION, groups: [] };
27+}
28+
29+/** Generates a stable id for a new config group. */
30+export function newGroupId() {
31+ return crypto.randomUUID();
32+}
33+
34+/**
35+ * Validates a config object. Returns { ok: true } or { ok: false, error }.
36+ * Used both by the options page (before save) and on import.
37+ * @param {unknown} config
38+ */
39+export function validateConfig(config) {
40+ if (!config || typeof config !== "object") {
41+ return { ok: false, error: t("validateNoConfig") };
42+ }
43+ if (!Array.isArray(config.groups)) {
44+ return { ok: false, error: t("validateGroupsNotList") };
45+ }
46+ const seenIds = new Set();
47+ for (const [i, group] of config.groups.entries()) {
48+ const label = group?.name ? `« ${group.name} »` : t("groupFallbackLabel", String(i + 1));
49+ if (!group || typeof group !== "object") {
50+ return { ok: false, error: t("validateGroupInvalid", label) };
51+ }
52+ if (typeof group.id !== "string" || group.id === "") {
53+ return { ok: false, error: t("validateGroupNoId", label) };
54+ }
55+ if (seenIds.has(group.id)) {
56+ return { ok: false, error: t("validateGroupDupId", label) };
57+ }
58+ seenIds.add(group.id);
59+ if (typeof group.name !== "string" || group.name.trim() === "") {
60+ return { ok: false, error: t("validateGroupNoName", label) };
61+ }
62+ if (!COLORS.includes(group.color)) {
63+ return { ok: false, error: t("validateGroupBadColor", label) };
64+ }
65+ if (!Array.isArray(group.patterns)) {
66+ return { ok: false, error: t("validateGroupBadPatterns", label) };
67+ }
68+ for (const pattern of group.patterns) {
69+ if (typeof pattern !== "string") {
70+ return { ok: false, error: t("validateGroupPatternNotString", label) };
71+ }
72+ try {
73+ new RegExp(pattern, "i");
74+ } catch (e) {
75+ return {
76+ ok: false,
77+ error: t("validateGroupBadRegex", [label, pattern, e.message]),
78+ };
79+ }
80+ }
81+ }
82+ return { ok: true };
83+}
84+
85+/**
86+ * Loads config from storage.local, falling back to an empty config.
87+ * @returns {Promise<{version: number, groups: []}>}
88+ */
89+export async function loadConfig() {
90+ const stored = await browser.storage.local.get(STORAGE_KEY);
91+ const config = stored[STORAGE_KEY];
92+ if (validateConfig(config).ok) return config;
93+ return emptyConfig();
94+}
95+
96+/**
97+ * Persists a config after validating it. Throws on invalid config.
98+ * @param {object} config
99+ */
100+export async function saveConfig(config) {
101+ const result = validateConfig(config);
102+ if (!result.ok) throw new Error(result.error);
103+ await browser.storage.local.set({ [STORAGE_KEY]: config });
104+}
105+
106+/**
107+ * Subscribes to config changes. Calls `callback(newConfig)` whenever the stored
108+ * config changes. Returns an unsubscribe function.
109+ * @param {(config: object) => void} callback
110+ */
111+export function onConfigChanged(callback) {
112+ const listener = (changes, area) => {
113+ if (area === "local" && changes[STORAGE_KEY]) {
114+ callback(changes[STORAGE_KEY].newValue ?? emptyConfig());
115+ }
116+ };
117+ browser.storage.onChanged.addListener(listener);
118+ return () => browser.storage.onChanged.removeListener(listener);
119+}
added src/grouper.js +166 -0
new file mode 100644
@@ -0,0 +1,166 @@
1+// The grouping engine. Translates tab/navigation events into tabGroups actions,
2+// honouring the behaviour locked in SPEC.md.
3+
4+import { bestGroup, isManageableUrl } from "./matcher.js";
5+import { loadConfig } from "./config.js";
6+import {
7+ pendingSelfMoves,
8+ getInstance,
9+ setInstance,
10+ forgetInstanceByRealId,
11+ getLastMatched,
12+ setLastMatched,
13+ forgetTab,
14+} from "./state.js";
15+
16+const NONE = browser.tabGroups?.TAB_GROUP_ID_NONE ?? -1;
17+
18+/** True when a tab currently belongs to some group. */
19+function isGrouped(tab) {
20+ return typeof tab.groupId === "number" && tab.groupId !== NONE;
21+}
22+
23+/**
24+ * Resolves the real tabGroups id that represents `group` inside `windowId`,
25+ * creating or adopting it if necessary. Returns the real group id.
26+ */
27+async function resolveRealGroup(group, windowId) {
28+ // 1. Trust the session instance map if it still points at a live group.
29+ const cached = await getInstance(group.id, windowId);
30+ if (cached != null) {
31+ try {
32+ const tg = await browser.tabGroups.get(cached);
33+ if (tg && tg.windowId === windowId) return cached;
34+ } catch {
35+ // stale (group was removed) — fall through to adopt/create
36+ }
37+ }
38+
39+ // 2. Adopt an existing group in this window that matches name + colour.
40+ // This re-attaches after the session map was cleared (restart) or lost.
41+ const existing = await browser.tabGroups.query({ windowId });
42+ const adopted = existing.find(
43+ (tg) => tg.title === group.name && tg.color === group.color,
44+ );
45+ if (adopted) {
46+ await setInstance(group.id, windowId, adopted.id);
47+ return adopted.id;
48+ }
49+
50+ return null; // caller must create it together with the first tab
51+}
52+
53+/**
54+ * Places `tab` into the group described by `group`, creating the per-window
55+ * group instance on demand. Never groups pinned tabs.
56+ */
57+async function placeTabInGroup(tab, group) {
58+ if (tab.pinned) return;
59+ const windowId = tab.windowId;
60+
61+ let realGroupId = await resolveRealGroup(group, windowId);
62+
63+ pendingSelfMoves.add(tab.id);
64+ try {
65+ if (realGroupId == null) {
66+ // Create a brand-new group seeded with this tab, then style it.
67+ realGroupId = await browser.tabs.group({
68+ tabIds: [tab.id],
69+ createProperties: { windowId },
70+ });
71+ await browser.tabGroups.update(realGroupId, {
72+ title: group.name,
73+ color: group.color,
74+ });
75+ await setInstance(group.id, windowId, realGroupId);
76+ } else if (tab.groupId !== realGroupId) {
77+ await browser.tabs.group({ tabIds: [tab.id], groupId: realGroupId });
78+ } else {
79+ // Already in the right group: nothing to move.
80+ pendingSelfMoves.delete(tab.id);
81+ }
82+ } catch (e) {
83+ pendingSelfMoves.delete(tab.id);
84+ console.warn("[auto-group] placeTabInGroup failed:", e);
85+ }
86+}
87+
88+/** Removes a tab from its group (used when a managed tab gets pinned). */
89+async function ungroupTab(tab) {
90+ if (!isGrouped(tab)) return;
91+ pendingSelfMoves.add(tab.id);
92+ try {
93+ await browser.tabs.ungroup(tab.id);
94+ } catch (e) {
95+ pendingSelfMoves.delete(tab.id);
96+ console.warn("[auto-group] ungroupTab failed:", e);
97+ }
98+}
99+
100+/**
101+ * Core rule applied whenever a tab's URL settles on a new value.
102+ * See SPEC.md for the override semantics.
103+ */
104+export async function evaluateTab(tab) {
105+ // Pinned tabs are never managed; a managed tab that becomes pinned leaves.
106+ if (tab.pinned) {
107+ if (isGrouped(tab)) await ungroupTab(tab);
108+ await setLastMatched(tab.id, null);
109+ return;
110+ }
111+
112+ if (!isManageableUrl(tab.url)) return; // non-http(s): leave untouched
113+
114+ const config = await loadConfig();
115+ const match = bestGroup(tab.url, config);
116+ const prev = await getLastMatched(tab.id);
117+ const matchId = match ? match.id : null;
118+
119+ await setLastMatched(tab.id, matchId);
120+
121+ if (match == null) return; // no match: ignored, never removed from a group
122+ if (matchId === prev) return; // same match as before: respect manual moves
123+
124+ // New/different match → (re)place the tab; this also lifts any manual override.
125+ await placeTabInGroup(tab, match);
126+}
127+
128+/**
129+ * Handles a tab whose groupId changed. If we initiated the change we consume the
130+ * marker; otherwise it was a manual move and we leave `lastMatched` untouched so
131+ * the override is respected until the URL changes toward a *different* group.
132+ */
133+export function handleGroupChange(tabId) {
134+ if (pendingSelfMoves.has(tabId)) {
135+ pendingSelfMoves.delete(tabId);
136+ return;
137+ }
138+ // Manual move: intentionally do nothing. The stored lastMatchedConfigGroupId
139+ // already encodes the override (evaluateTab won't move it back to the same
140+ // group).
141+}
142+
143+/**
144+ * Force-sorts every manageable tab into its matching group, ignoring overrides.
145+ * Triggered by the "Ré-organiser maintenant" button.
146+ */
147+export async function reorganizeAll() {
148+ const config = await loadConfig();
149+ const tabs = await browser.tabs.query({});
150+ for (const tab of tabs) {
151+ if (tab.pinned || !isManageableUrl(tab.url)) continue;
152+ const match = bestGroup(tab.url, config);
153+ await setLastMatched(tab.id, match ? match.id : null);
154+ if (match) await placeTabInGroup(tab, match);
155+ }
156+}
157+
158+/** Cleanup when a group disappears (Firefox auto-removes empty groups). */
159+export async function handleGroupRemoved(group) {
160+ await forgetInstanceByRealId(group.id);
161+}
162+
163+/** Cleanup when a tab closes. */
164+export async function handleTabRemoved(tabId) {
165+ await forgetTab(tabId);
166+}
new file mode 100644
@@ -0,0 +1,166 @@
1+// The grouping engine. Translates tab/navigation events into tabGroups actions,
2+// honouring the behaviour locked in SPEC.md.
3+
4+import { bestGroup, isManageableUrl } from "./matcher.js";
5+import { loadConfig } from "./config.js";
6+import {
7+ pendingSelfMoves,
8+ getInstance,
9+ setInstance,
10+ forgetInstanceByRealId,
11+ getLastMatched,
12+ setLastMatched,
13+ forgetTab,
14+} from "./state.js";
15+
16+const NONE = browser.tabGroups?.TAB_GROUP_ID_NONE ?? -1;
17+
18+/** True when a tab currently belongs to some group. */
19+function isGrouped(tab) {
20+ return typeof tab.groupId === "number" && tab.groupId !== NONE;
21+}
22+
23+/**
24+ * Resolves the real tabGroups id that represents `group` inside `windowId`,
25+ * creating or adopting it if necessary. Returns the real group id.
26+ */
27+async function resolveRealGroup(group, windowId) {
28+ // 1. Trust the session instance map if it still points at a live group.
29+ const cached = await getInstance(group.id, windowId);
30+ if (cached != null) {
31+ try {
32+ const tg = await browser.tabGroups.get(cached);
33+ if (tg && tg.windowId === windowId) return cached;
34+ } catch {
35+ // stale (group was removed) — fall through to adopt/create
36+ }
37+ }
38+
39+ // 2. Adopt an existing group in this window that matches name + colour.
40+ // This re-attaches after the session map was cleared (restart) or lost.
41+ const existing = await browser.tabGroups.query({ windowId });
42+ const adopted = existing.find(
43+ (tg) => tg.title === group.name && tg.color === group.color,
44+ );
45+ if (adopted) {
46+ await setInstance(group.id, windowId, adopted.id);
47+ return adopted.id;
48+ }
49+
50+ return null; // caller must create it together with the first tab
51+}
52+
53+/**
54+ * Places `tab` into the group described by `group`, creating the per-window
55+ * group instance on demand. Never groups pinned tabs.
56+ */
57+async function placeTabInGroup(tab, group) {
58+ if (tab.pinned) return;
59+ const windowId = tab.windowId;
60+
61+ let realGroupId = await resolveRealGroup(group, windowId);
62+
63+ pendingSelfMoves.add(tab.id);
64+ try {
65+ if (realGroupId == null) {
66+ // Create a brand-new group seeded with this tab, then style it.
67+ realGroupId = await browser.tabs.group({
68+ tabIds: [tab.id],
69+ createProperties: { windowId },
70+ });
71+ await browser.tabGroups.update(realGroupId, {
72+ title: group.name,
73+ color: group.color,
74+ });
75+ await setInstance(group.id, windowId, realGroupId);
76+ } else if (tab.groupId !== realGroupId) {
77+ await browser.tabs.group({ tabIds: [tab.id], groupId: realGroupId });
78+ } else {
79+ // Already in the right group: nothing to move.
80+ pendingSelfMoves.delete(tab.id);
81+ }
82+ } catch (e) {
83+ pendingSelfMoves.delete(tab.id);
84+ console.warn("[auto-group] placeTabInGroup failed:", e);
85+ }
86+}
87+
88+/** Removes a tab from its group (used when a managed tab gets pinned). */
89+async function ungroupTab(tab) {
90+ if (!isGrouped(tab)) return;
91+ pendingSelfMoves.add(tab.id);
92+ try {
93+ await browser.tabs.ungroup(tab.id);
94+ } catch (e) {
95+ pendingSelfMoves.delete(tab.id);
96+ console.warn("[auto-group] ungroupTab failed:", e);
97+ }
98+}
99+
100+/**
101+ * Core rule applied whenever a tab's URL settles on a new value.
102+ * See SPEC.md for the override semantics.
103+ */
104+export async function evaluateTab(tab) {
105+ // Pinned tabs are never managed; a managed tab that becomes pinned leaves.
106+ if (tab.pinned) {
107+ if (isGrouped(tab)) await ungroupTab(tab);
108+ await setLastMatched(tab.id, null);
109+ return;
110+ }
111+
112+ if (!isManageableUrl(tab.url)) return; // non-http(s): leave untouched
113+
114+ const config = await loadConfig();
115+ const match = bestGroup(tab.url, config);
116+ const prev = await getLastMatched(tab.id);
117+ const matchId = match ? match.id : null;
118+
119+ await setLastMatched(tab.id, matchId);
120+
121+ if (match == null) return; // no match: ignored, never removed from a group
122+ if (matchId === prev) return; // same match as before: respect manual moves
123+
124+ // New/different match → (re)place the tab; this also lifts any manual override.
125+ await placeTabInGroup(tab, match);
126+}
127+
128+/**
129+ * Handles a tab whose groupId changed. If we initiated the change we consume the
130+ * marker; otherwise it was a manual move and we leave `lastMatched` untouched so
131+ * the override is respected until the URL changes toward a *different* group.
132+ */
133+export function handleGroupChange(tabId) {
134+ if (pendingSelfMoves.has(tabId)) {
135+ pendingSelfMoves.delete(tabId);
136+ return;
137+ }
138+ // Manual move: intentionally do nothing. The stored lastMatchedConfigGroupId
139+ // already encodes the override (evaluateTab won't move it back to the same
140+ // group).
141+}
142+
143+/**
144+ * Force-sorts every manageable tab into its matching group, ignoring overrides.
145+ * Triggered by the "Ré-organiser maintenant" button.
146+ */
147+export async function reorganizeAll() {
148+ const config = await loadConfig();
149+ const tabs = await browser.tabs.query({});
150+ for (const tab of tabs) {
151+ if (tab.pinned || !isManageableUrl(tab.url)) continue;
152+ const match = bestGroup(tab.url, config);
153+ await setLastMatched(tab.id, match ? match.id : null);
154+ if (match) await placeTabInGroup(tab, match);
155+ }
156+}
157+
158+/** Cleanup when a group disappears (Firefox auto-removes empty groups). */
159+export async function handleGroupRemoved(group) {
160+ await forgetInstanceByRealId(group.id);
161+}
162+
163+/** Cleanup when a tab closes. */
164+export async function handleTabRemoved(tabId) {
165+ await forgetTab(tabId);
166+}
added src/i18n.js +44 -0
new file mode 100644
@@ -0,0 +1,44 @@
1+// Thin wrapper around the WebExtensions i18n API + a DOM applicator.
2+//
3+// Firefox picks the locale from the browser UI language, falling back to the
4+// manifest's default_locale. HTML has no automatic __MSG__ substitution, so we
5+// fill marked nodes here:
6+// <el data-i18n="key"> → textContent
7+// <el data-i18n-placeholder="key"> → placeholder attribute
8+// <el data-i18n-title="key"> → title attribute
9+// <el data-i18n-aria-label="key"> → aria-label attribute
10+
11+/**
12+ * Returns the localized string for `key`, with optional substitutions
13+ * (referenced as $1, $2… via the message's placeholders).
14+ * @param {string} key
15+ * @param {string|string[]} [subs]
16+ * @returns {string}
17+ */
18+export function t(key, subs) {
19+ return browser.i18n.getMessage(key, subs);
20+}
21+
22+const ATTR_BINDINGS = [
23+ ["data-i18n-placeholder", "placeholder"],
24+ ["data-i18n-title", "title"],
25+ ["data-i18n-aria-label", "aria-label"],
26+];
27+
28+/**
29+ * Localizes all marked nodes within `root` (descendants only). Call again on
30+ * freshly cloned template fragments.
31+ * @param {ParentNode} [root=document]
32+ */
33+export function applyI18n(root = document) {
34+ for (const el of root.querySelectorAll("[data-i18n]")) {
35+ const msg = t(el.getAttribute("data-i18n"));
36+ if (msg) el.textContent = msg;
37+ }
38+ for (const [srcAttr, destAttr] of ATTR_BINDINGS) {
39+ for (const el of root.querySelectorAll(`[${srcAttr}]`)) {
40+ const msg = t(el.getAttribute(srcAttr));
41+ if (msg) el.setAttribute(destAttr, msg);
42+ }
43+ }
44+}
new file mode 100644
@@ -0,0 +1,44 @@
1+// Thin wrapper around the WebExtensions i18n API + a DOM applicator.
2+//
3+// Firefox picks the locale from the browser UI language, falling back to the
4+// manifest's default_locale. HTML has no automatic __MSG__ substitution, so we
5+// fill marked nodes here:
6+// <el data-i18n="key"> → textContent
7+// <el data-i18n-placeholder="key"> → placeholder attribute
8+// <el data-i18n-title="key"> → title attribute
9+// <el data-i18n-aria-label="key"> → aria-label attribute
10+
11+/**
12+ * Returns the localized string for `key`, with optional substitutions
13+ * (referenced as $1, $2… via the message's placeholders).
14+ * @param {string} key
15+ * @param {string|string[]} [subs]
16+ * @returns {string}
17+ */
18+export function t(key, subs) {
19+ return browser.i18n.getMessage(key, subs);
20+}
21+
22+const ATTR_BINDINGS = [
23+ ["data-i18n-placeholder", "placeholder"],
24+ ["data-i18n-title", "title"],
25+ ["data-i18n-aria-label", "aria-label"],
26+];
27+
28+/**
29+ * Localizes all marked nodes within `root` (descendants only). Call again on
30+ * freshly cloned template fragments.
31+ * @param {ParentNode} [root=document]
32+ */
33+export function applyI18n(root = document) {
34+ for (const el of root.querySelectorAll("[data-i18n]")) {
35+ const msg = t(el.getAttribute("data-i18n"));
36+ if (msg) el.textContent = msg;
37+ }
38+ for (const [srcAttr, destAttr] of ATTR_BINDINGS) {
39+ for (const el of root.querySelectorAll(`[${srcAttr}]`)) {
40+ const msg = t(el.getAttribute(srcAttr));
41+ if (msg) el.setAttribute(destAttr, msg);
42+ }
43+ }
44+}
added src/matcher.js +72 -0
new file mode 100644
@@ -0,0 +1,72 @@
1+// Pure URL → group matching logic. No browser APIs here, so it can be unit-tested
2+// under plain Node.
3+
4+/** Schemes the extension is allowed to manage. Everything else is ignored. */
5+const ALLOWED_SCHEMES = new Set(["http:", "https:"]);
6+
7+/**
8+ * Returns true when `url` is an http(s) URL we are allowed to group.
9+ * @param {string} url
10+ * @returns {boolean}
11+ */
12+export function isManageableUrl(url) {
13+ if (typeof url !== "string" || url === "") return false;
14+ try {
15+ return ALLOWED_SCHEMES.has(new URL(url).protocol);
16+ } catch {
17+ return false;
18+ }
19+}
20+
21+/**
22+ * Length of the substring matched by `pattern` against `url`, or -1 if no match.
23+ * Case-insensitive. An invalid pattern never matches (returns -1).
24+ * @param {string} pattern
25+ * @param {string} url
26+ * @returns {number}
27+ */
28+function matchedLength(pattern, url) {
29+ let re;
30+ try {
31+ re = new RegExp(pattern, "i");
32+ } catch {
33+ return -1;
34+ }
35+ const m = re.exec(url);
36+ return m ? m[0].length : -1;
37+}
38+
39+/**
40+ * Picks the best matching config group for a URL.
41+ *
42+ * Rules (see SPEC.md):
43+ * - non-http(s) URLs never match;
44+ * - among all groups whose patterns match, the winner is the one with the
45+ * longest matched substring;
46+ * - ties are broken by config order (earliest group wins).
47+ *
48+ * @param {string} url
49+ * @param {{groups: Array<{id: string, patterns: string[]}>}} config
50+ * @returns {{id: string, patterns: string[]}|null} the winning group, or null
51+ */
52+export function bestGroup(url, config) {
53+ if (!isManageableUrl(url)) return null;
54+ const groups = config?.groups ?? [];
55+
56+ let best = null;
57+ let bestLen = -1; // strictly-greater comparison keeps the earliest group on ties
58+
59+ for (const group of groups) {
60+ let groupLen = -1;
61+ for (const pattern of group.patterns ?? []) {
62+ const len = matchedLength(pattern, url);
63+ if (len > groupLen) groupLen = len;
64+ }
65+ if (groupLen > bestLen) {
66+ bestLen = groupLen;
67+ best = group;
68+ }
69+ }
70+
71+ return bestLen >= 0 ? best : null;
72+}
new file mode 100644
@@ -0,0 +1,72 @@
1+// Pure URL → group matching logic. No browser APIs here, so it can be unit-tested
2+// under plain Node.
3+
4+/** Schemes the extension is allowed to manage. Everything else is ignored. */
5+const ALLOWED_SCHEMES = new Set(["http:", "https:"]);
6+
7+/**
8+ * Returns true when `url` is an http(s) URL we are allowed to group.
9+ * @param {string} url
10+ * @returns {boolean}
11+ */
12+export function isManageableUrl(url) {
13+ if (typeof url !== "string" || url === "") return false;
14+ try {
15+ return ALLOWED_SCHEMES.has(new URL(url).protocol);
16+ } catch {
17+ return false;
18+ }
19+}
20+
21+/**
22+ * Length of the substring matched by `pattern` against `url`, or -1 if no match.
23+ * Case-insensitive. An invalid pattern never matches (returns -1).
24+ * @param {string} pattern
25+ * @param {string} url
26+ * @returns {number}
27+ */
28+function matchedLength(pattern, url) {
29+ let re;
30+ try {
31+ re = new RegExp(pattern, "i");
32+ } catch {
33+ return -1;
34+ }
35+ const m = re.exec(url);
36+ return m ? m[0].length : -1;
37+}
38+
39+/**
40+ * Picks the best matching config group for a URL.
41+ *
42+ * Rules (see SPEC.md):
43+ * - non-http(s) URLs never match;
44+ * - among all groups whose patterns match, the winner is the one with the
45+ * longest matched substring;
46+ * - ties are broken by config order (earliest group wins).
47+ *
48+ * @param {string} url
49+ * @param {{groups: Array<{id: string, patterns: string[]}>}} config
50+ * @returns {{id: string, patterns: string[]}|null} the winning group, or null
51+ */
52+export function bestGroup(url, config) {
53+ if (!isManageableUrl(url)) return null;
54+ const groups = config?.groups ?? [];
55+
56+ let best = null;
57+ let bestLen = -1; // strictly-greater comparison keeps the earliest group on ties
58+
59+ for (const group of groups) {
60+ let groupLen = -1;
61+ for (const pattern of group.patterns ?? []) {
62+ const len = matchedLength(pattern, url);
63+ if (len > groupLen) groupLen = len;
64+ }
65+ if (groupLen > bestLen) {
66+ bestLen = groupLen;
67+ best = group;
68+ }
69+ }
70+
71+ return bestLen >= 0 ? best : null;
72+}
added src/state.js +81 -0
new file mode 100644
@@ -0,0 +1,81 @@
1+// Runtime state for the grouping engine.
2+//
3+// Persisted in storage.session (cleared on browser restart, survives event-page
4+// sleep — exactly the lifetime we want for the manual-override semantics):
5+// - instanceMap: { [configGroupId]: { [windowId]: realTabGroupId } }
6+// - tabState: { [tabId]: { lastMatchedConfigGroupId: string | null } }
7+//
8+// In-memory only (valid for the duration of an active operation):
9+// - pendingSelfMoves: tab ids whose group change was initiated by us, so the
10+// resulting tabs.onUpdated(groupId) event is not misread as a manual move.
11+
12+const INSTANCE_MAP_KEY = "instanceMap";
13+const TAB_STATE_KEY = "tabState";
14+
15+/** @type {Set<number>} */
16+export const pendingSelfMoves = new Set();
17+
18+async function readSession(key, fallback) {
19+ const stored = await browser.storage.session.get(key);
20+ return stored[key] ?? fallback;
21+}
22+
23+function writeSession(key, value) {
24+ return browser.storage.session.set({ [key]: value });
25+}
26+
27+// --- instance map ---------------------------------------------------------
28+
29+/** Returns the real tabGroup id for (configGroupId, windowId), or undefined. */
30+export async function getInstance(configGroupId, windowId) {
31+ const map = await readSession(INSTANCE_MAP_KEY, {});
32+ return map[configGroupId]?.[windowId];
33+}
34+
35+/** Records the real tabGroup id for (configGroupId, windowId). */
36+export async function setInstance(configGroupId, windowId, realGroupId) {
37+ const map = await readSession(INSTANCE_MAP_KEY, {});
38+ if (!map[configGroupId]) map[configGroupId] = {};
39+ map[configGroupId][windowId] = realGroupId;
40+ await writeSession(INSTANCE_MAP_KEY, map);
41+}
42+
43+/** Removes any instance-map entries pointing at `realGroupId` (group removed). */
44+export async function forgetInstanceByRealId(realGroupId) {
45+ const map = await readSession(INSTANCE_MAP_KEY, {});
46+ let changed = false;
47+ for (const configGroupId of Object.keys(map)) {
48+ for (const windowId of Object.keys(map[configGroupId])) {
49+ if (map[configGroupId][windowId] === realGroupId) {
50+ delete map[configGroupId][windowId];
51+ changed = true;
52+ }
53+ }
54+ if (Object.keys(map[configGroupId]).length === 0) delete map[configGroupId];
55+ }
56+ if (changed) await writeSession(INSTANCE_MAP_KEY, map);
57+}
58+
59+// --- per-tab state --------------------------------------------------------
60+
61+/** Returns the config group a tab's URL last matched, or null. */
62+export async function getLastMatched(tabId) {
63+ const state = await readSession(TAB_STATE_KEY, {});
64+ return state[tabId]?.lastMatchedConfigGroupId ?? null;
65+}
66+
67+/** Records the config group a tab's URL last matched (null = no match). */
68+export async function setLastMatched(tabId, configGroupId) {
69+ const state = await readSession(TAB_STATE_KEY, {});
70+ state[tabId] = { lastMatchedConfigGroupId: configGroupId };
71+ await writeSession(TAB_STATE_KEY, state);
72+}
73+
74+/** Drops all stored state for a closed tab. */
75+export async function forgetTab(tabId) {
76+ const state = await readSession(TAB_STATE_KEY, {});
77+ if (tabId in state) {
78+ delete state[tabId];
79+ await writeSession(TAB_STATE_KEY, state);
80+ }
81+}
new file mode 100644
@@ -0,0 +1,81 @@
1+// Runtime state for the grouping engine.
2+//
3+// Persisted in storage.session (cleared on browser restart, survives event-page
4+// sleep — exactly the lifetime we want for the manual-override semantics):
5+// - instanceMap: { [configGroupId]: { [windowId]: realTabGroupId } }
6+// - tabState: { [tabId]: { lastMatchedConfigGroupId: string | null } }
7+//
8+// In-memory only (valid for the duration of an active operation):
9+// - pendingSelfMoves: tab ids whose group change was initiated by us, so the
10+// resulting tabs.onUpdated(groupId) event is not misread as a manual move.
11+
12+const INSTANCE_MAP_KEY = "instanceMap";
13+const TAB_STATE_KEY = "tabState";
14+
15+/** @type {Set<number>} */
16+export const pendingSelfMoves = new Set();
17+
18+async function readSession(key, fallback) {
19+ const stored = await browser.storage.session.get(key);
20+ return stored[key] ?? fallback;
21+}
22+
23+function writeSession(key, value) {
24+ return browser.storage.session.set({ [key]: value });
25+}
26+
27+// --- instance map ---------------------------------------------------------
28+
29+/** Returns the real tabGroup id for (configGroupId, windowId), or undefined. */
30+export async function getInstance(configGroupId, windowId) {
31+ const map = await readSession(INSTANCE_MAP_KEY, {});
32+ return map[configGroupId]?.[windowId];
33+}
34+
35+/** Records the real tabGroup id for (configGroupId, windowId). */
36+export async function setInstance(configGroupId, windowId, realGroupId) {
37+ const map = await readSession(INSTANCE_MAP_KEY, {});
38+ if (!map[configGroupId]) map[configGroupId] = {};
39+ map[configGroupId][windowId] = realGroupId;
40+ await writeSession(INSTANCE_MAP_KEY, map);
41+}
42+
43+/** Removes any instance-map entries pointing at `realGroupId` (group removed). */
44+export async function forgetInstanceByRealId(realGroupId) {
45+ const map = await readSession(INSTANCE_MAP_KEY, {});
46+ let changed = false;
47+ for (const configGroupId of Object.keys(map)) {
48+ for (const windowId of Object.keys(map[configGroupId])) {
49+ if (map[configGroupId][windowId] === realGroupId) {
50+ delete map[configGroupId][windowId];
51+ changed = true;
52+ }
53+ }
54+ if (Object.keys(map[configGroupId]).length === 0) delete map[configGroupId];
55+ }
56+ if (changed) await writeSession(INSTANCE_MAP_KEY, map);
57+}
58+
59+// --- per-tab state --------------------------------------------------------
60+
61+/** Returns the config group a tab's URL last matched, or null. */
62+export async function getLastMatched(tabId) {
63+ const state = await readSession(TAB_STATE_KEY, {});
64+ return state[tabId]?.lastMatchedConfigGroupId ?? null;
65+}
66+
67+/** Records the config group a tab's URL last matched (null = no match). */
68+export async function setLastMatched(tabId, configGroupId) {
69+ const state = await readSession(TAB_STATE_KEY, {});
70+ state[tabId] = { lastMatchedConfigGroupId: configGroupId };
71+ await writeSession(TAB_STATE_KEY, state);
72+}
73+
74+/** Drops all stored state for a closed tab. */
75+export async function forgetTab(tabId) {
76+ const state = await readSession(TAB_STATE_KEY, {});
77+ if (tabId in state) {
78+ delete state[tabId];
79+ await writeSession(TAB_STATE_KEY, state);
80+ }
81+}
added test/matcher.test.mjs +84 -0
new file mode 100644
@@ -0,0 +1,84 @@
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+import { bestGroup, isManageableUrl } from "../src/matcher.js";
4+
5+const cfg = (...groups) => ({ version: 1, groups });
6+const g = (id, ...patterns) => ({ id, name: id, color: "blue", patterns });
7+
8+test("only http(s) URLs are manageable", () => {
9+ assert.equal(isManageableUrl("https://example.com"), true);
10+ assert.equal(isManageableUrl("http://example.com"), true);
11+ assert.equal(isManageableUrl("about:config"), false);
12+ assert.equal(isManageableUrl("file:///tmp/x"), false);
13+ assert.equal(isManageableUrl("moz-extension://abc/x"), false);
14+ assert.equal(isManageableUrl(""), false);
15+ assert.equal(isManageableUrl(undefined), false);
16+});
17+
18+test("non-http(s) URL never matches a group", () => {
19+ const config = cfg(g("a", ".*"));
20+ assert.equal(bestGroup("about:config", config), null);
21+});
22+
23+test("no match returns null", () => {
24+ const config = cfg(g("a", "github\\.com"));
25+ assert.equal(bestGroup("https://example.com", config), null);
26+});
27+
28+test("longest matched substring wins across groups", () => {
29+ // Both match, but group B's pattern matches a longer substring.
30+ const config = cfg(
31+ g("short", "github\\.com"),
32+ g("long", "github\\.com/anthropics/claude"),
33+ );
34+ assert.equal(
35+ bestGroup("https://github.com/anthropics/claude/issues", config).id,
36+ "long",
37+ );
38+});
39+
40+test("longest substring wins regardless of config order", () => {
41+ const config = cfg(
42+ g("long", "github\\.com/anthropics/claude"),
43+ g("short", "github\\.com"),
44+ );
45+ assert.equal(
46+ bestGroup("https://github.com/anthropics/claude/issues", config).id,
47+ "long",
48+ );
49+});
50+
51+test("ties broken by config order (earliest wins)", () => {
52+ const config = cfg(g("first", "example\\.com"), g("second", "example\\.com"));
53+ assert.equal(bestGroup("https://example.com/x", config).id, "first");
54+});
55+
56+test("match is case-insensitive", () => {
57+ const config = cfg(g("a", "github\\.com"));
58+ assert.equal(bestGroup("https://GitHub.COM/x", config).id, "a");
59+});
60+
61+test("a group's best pattern is used", () => {
62+ const config = cfg(
63+ g("multi", "nope", "github\\.com/anthropics"),
64+ g("other", "github\\.com"),
65+ );
66+ assert.equal(
67+ bestGroup("https://github.com/anthropics/x", config).id,
68+ "multi",
69+ );
70+});
71+
72+test("invalid regexp is ignored, valid sibling still matches", () => {
73+ const config = cfg(g("a", "(unclosed", "example\\.com"));
74+ assert.equal(bestGroup("https://example.com", config).id, "a");
75+});
76+
77+test("a group with only an invalid regexp never matches", () => {
78+ const config = cfg(g("bad", "(unclosed"), g("good", "example"));
79+ assert.equal(bestGroup("https://example.com", config).id, "good");
80+});
81+
82+test("empty config returns null", () => {
83+ assert.equal(bestGroup("https://example.com", { version: 1, groups: [] }), null);
84+});
new file mode 100644
@@ -0,0 +1,84 @@
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+import { bestGroup, isManageableUrl } from "../src/matcher.js";
4+
5+const cfg = (...groups) => ({ version: 1, groups });
6+const g = (id, ...patterns) => ({ id, name: id, color: "blue", patterns });
7+
8+test("only http(s) URLs are manageable", () => {
9+ assert.equal(isManageableUrl("https://example.com"), true);
10+ assert.equal(isManageableUrl("http://example.com"), true);
11+ assert.equal(isManageableUrl("about:config"), false);
12+ assert.equal(isManageableUrl("file:///tmp/x"), false);
13+ assert.equal(isManageableUrl("moz-extension://abc/x"), false);
14+ assert.equal(isManageableUrl(""), false);
15+ assert.equal(isManageableUrl(undefined), false);
16+});
17+
18+test("non-http(s) URL never matches a group", () => {
19+ const config = cfg(g("a", ".*"));
20+ assert.equal(bestGroup("about:config", config), null);
21+});
22+
23+test("no match returns null", () => {
24+ const config = cfg(g("a", "github\\.com"));
25+ assert.equal(bestGroup("https://example.com", config), null);
26+});
27+
28+test("longest matched substring wins across groups", () => {
29+ // Both match, but group B's pattern matches a longer substring.
30+ const config = cfg(
31+ g("short", "github\\.com"),
32+ g("long", "github\\.com/anthropics/claude"),
33+ );
34+ assert.equal(
35+ bestGroup("https://github.com/anthropics/claude/issues", config).id,
36+ "long",
37+ );
38+});
39+
40+test("longest substring wins regardless of config order", () => {
41+ const config = cfg(
42+ g("long", "github\\.com/anthropics/claude"),
43+ g("short", "github\\.com"),
44+ );
45+ assert.equal(
46+ bestGroup("https://github.com/anthropics/claude/issues", config).id,
47+ "long",
48+ );
49+});
50+
51+test("ties broken by config order (earliest wins)", () => {
52+ const config = cfg(g("first", "example\\.com"), g("second", "example\\.com"));
53+ assert.equal(bestGroup("https://example.com/x", config).id, "first");
54+});
55+
56+test("match is case-insensitive", () => {
57+ const config = cfg(g("a", "github\\.com"));
58+ assert.equal(bestGroup("https://GitHub.COM/x", config).id, "a");
59+});
60+
61+test("a group's best pattern is used", () => {
62+ const config = cfg(
63+ g("multi", "nope", "github\\.com/anthropics"),
64+ g("other", "github\\.com"),
65+ );
66+ assert.equal(
67+ bestGroup("https://github.com/anthropics/x", config).id,
68+ "multi",
69+ );
70+});
71+
72+test("invalid regexp is ignored, valid sibling still matches", () => {
73+ const config = cfg(g("a", "(unclosed", "example\\.com"));
74+ assert.equal(bestGroup("https://example.com", config).id, "a");
75+});
76+
77+test("a group with only an invalid regexp never matches", () => {
78+ const config = cfg(g("bad", "(unclosed"), g("good", "example"));
79+ assert.equal(bestGroup("https://example.com", config).id, "good");
80+});
81+
82+test("empty config returns null", () => {
83+ assert.equal(bestGroup("https://example.com", { version: 1, groups: [] }), null);
84+});
added web-ext-config.cjs +17 -0
new file mode 100644
@@ -0,0 +1,17 @@
1+// Configuration web-ext (auto-chargée depuis le dossier courant).
2+// Exclut les fichiers de développement du paquet livré/signé.
3+module.exports = {
4+ ignoreFiles: [
5+ "**/*.md",
6+ "test/**",
7+ ".code-review-graph/**",
8+ "web-ext-config.cjs",
9+ "web-ext-artifacts/**",
10+ "package.json",
11+ "package-lock.json",
12+ "node_modules/**",
13+ ],
14+ build: {
15+ overwriteDest: true,
16+ },
17+};
new file mode 100644
@@ -0,0 +1,17 @@
1+// Configuration web-ext (auto-chargée depuis le dossier courant).
2+// Exclut les fichiers de développement du paquet livré/signé.
3+module.exports = {
4+ ignoreFiles: [
5+ "**/*.md",
6+ "test/**",
7+ ".code-review-graph/**",
8+ "web-ext-config.cjs",
9+ "web-ext-artifacts/**",
10+ "package.json",
11+ "package-lock.json",
12+ "node_modules/**",
13+ ],
14+ build: {
15+ overwriteDest: true,
16+ },
17+};