Release 0.0.1Unverified
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 differ | Binary 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 differ | Binary 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 | +}; | ||