diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4a0520aa930..16c51300ee1 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -43,7 +43,7 @@ repos: hooks: - id: whool-init - repo: https://github.com/oca/maintainer-tools - rev: bf9ecb9938b6a5deca0ff3d870fbd3f33341fded + rev: 1a451eff05ce754b25428acc5d542e44050545b0 hooks: # update the NOT INSTALLABLE ADDONS section above - id: oca-update-pre-commit-excluded-addons @@ -60,7 +60,7 @@ repos: - --convert-fragments-to-markdown - id: oca-gen-external-dependencies - repo: https://github.com/OCA/odoo-pre-commit-hooks - rev: v0.0.33 + rev: v0.1.1 hooks: - id: oca-checks-odoo-module - id: oca-checks-po @@ -96,7 +96,7 @@ repos: - "eslint@9.12.0" - "eslint-plugin-jsdoc@50.3.1" - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.6.0 + rev: v5.0.0 hooks: - id: trailing-whitespace # exclude autogenerated files @@ -118,13 +118,13 @@ repos: - id: mixed-line-ending args: ["--fix=lf"] - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.6.8 + rev: v0.9.7 hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] - id: ruff-format - repo: https://github.com/OCA/pylint-odoo - rev: v9.1.3 + rev: v9.3.2 hooks: - id: pylint_odoo name: pylint with optional checks diff --git a/web_favicon/__manifest__.py b/web_favicon/__manifest__.py index 4f55e4a7af8..3dce9e1ec5d 100644 --- a/web_favicon/__manifest__.py +++ b/web_favicon/__manifest__.py @@ -6,10 +6,7 @@ { "name": "Custom shortcut icon", "version": "18.0.1.0.0", - "author": "Therp BV, " - "Tecnativa, " - "OERP Canada," - "Odoo Community Association (OCA)", + "author": "Therp BV, Tecnativa, OERP Canada,Odoo Community Association (OCA)", "license": "AGPL-3", "category": "Website", "summary": "Allows to set a custom shortcut icon (aka favicon)", diff --git a/web_iconify/README.rst b/web_iconify/README.rst new file mode 100644 index 00000000000..b8f1ecff147 --- /dev/null +++ b/web_iconify/README.rst @@ -0,0 +1,101 @@ +=========== +Web Iconify +=========== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:6eadd39cc54620c27d3acb20fb2d6a05543446e8a7e66fa53253a519afd19096 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fweb-lightgray.png?logo=github + :target: https://github.com/OCA/web/tree/18.0/web_iconify + :alt: OCA/web +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/web-18-0/web-18-0-web_iconify + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/web&target_branch=18.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module integrates the Iconify Icon Web Component into Odoo, +allowing developers to easily use a wide variety of icons in their +modules. It uses Iconify's on-demand loading feature. + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +To use icons in your Odoo views, use the following syntax: + +.. code:: html + + + +Replace prefix:name with the desired icon name. For example, for +Material Design Icons, you would use mdi:home. You can find a list of +available icons and icon sets on the Iconify website: + +https://iconify.design/ + +Changelog +========= + +18.0.1.0.0 (2025-02-23) +----------------------- + +- Initial version. + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* jaco.tech + +Contributors +------------ + +- Jaco Waes + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +This module is part of the `OCA/web `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/web_iconify/__init__.py b/web_iconify/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/web_iconify/__manifest__.py b/web_iconify/__manifest__.py new file mode 100644 index 00000000000..ec444d50338 --- /dev/null +++ b/web_iconify/__manifest__.py @@ -0,0 +1,23 @@ +# Copyright 2025 jaco.tech +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +{ + "name": "Web Iconify", + "version": "18.0.1.0.0", + "category": "Web", + "summary": "Provides core Iconify integration for Odoo. This module " + "integrates the Iconify SVG framework into Odoo, allowing developers " + "to easily use a wide variety of icons in their modules.", + "author": "jaco.tech, Odoo Community Association (OCA)", + "website": "https://github.com/OCA/web", + "depends": ["web"], + "license": "AGPL-3", + "assets": { + "web.assets_backend": [ + "/web_iconify/static/lib/iconify/iconify-icon.js", + "/web_iconify/static/src/css/web_iconify.css", + ], + }, + "installable": True, + "auto_install": False, +} diff --git a/web_iconify/pyproject.toml b/web_iconify/pyproject.toml new file mode 100644 index 00000000000..4231d0cccb3 --- /dev/null +++ b/web_iconify/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/web_iconify/readme/CONTRIBUTORS.md b/web_iconify/readme/CONTRIBUTORS.md new file mode 100644 index 00000000000..2541b203029 --- /dev/null +++ b/web_iconify/readme/CONTRIBUTORS.md @@ -0,0 +1 @@ +- Jaco Waes \ diff --git a/web_iconify/readme/DESCRIPTION.md b/web_iconify/readme/DESCRIPTION.md new file mode 100644 index 00000000000..f4762b54459 --- /dev/null +++ b/web_iconify/readme/DESCRIPTION.md @@ -0,0 +1,3 @@ +This module integrates the Iconify Icon Web Component into Odoo, +allowing developers to easily use a wide variety of icons in their +modules. It uses Iconify's on-demand loading feature. diff --git a/web_iconify/readme/HISTORY.md b/web_iconify/readme/HISTORY.md new file mode 100644 index 00000000000..90f6389743d --- /dev/null +++ b/web_iconify/readme/HISTORY.md @@ -0,0 +1,3 @@ +## 18.0.1.0.0 (2025-02-23) + +- Initial version. diff --git a/web_iconify/readme/USAGE.md b/web_iconify/readme/USAGE.md new file mode 100644 index 00000000000..0754c4efaab --- /dev/null +++ b/web_iconify/readme/USAGE.md @@ -0,0 +1,11 @@ +To use icons in your Odoo views, use the following syntax: + +``` html + +``` + +Replace prefix:name with the desired icon name. For example, for +Material Design Icons, you would use mdi:home. You can find a list of +available icons and icon sets on the Iconify website: + + diff --git a/web_iconify/static/description/index.html b/web_iconify/static/description/index.html new file mode 100644 index 00000000000..0ae2973282f --- /dev/null +++ b/web_iconify/static/description/index.html @@ -0,0 +1,450 @@ + + + + + +Web Iconify + + + +
+

Web Iconify

+ + +

Beta License: AGPL-3 OCA/web Translate me on Weblate Try me on Runboat

+

This module integrates the Iconify Icon Web Component into Odoo, +allowing developers to easily use a wide variety of icons in their +modules. It uses Iconify’s on-demand loading feature.

+

Table of contents

+ +
+

Usage

+

To use icons in your Odoo views, use the following syntax:

+
+<iconify-icon icon="prefix:name"></iconify-icon>
+
+

Replace prefix:name with the desired icon name. For example, for +Material Design Icons, you would use mdi:home. You can find a list of +available icons and icon sets on the Iconify website:

+

https://iconify.design/

+
+
+

Changelog

+
+

18.0.1.0.0 (2025-02-23)

+
    +
  • Initial version.
  • +
+
+
+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • jaco.tech
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

This module is part of the OCA/web project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/web_iconify/static/lib/iconify/iconify-icon.js b/web_iconify/static/lib/iconify/iconify-icon.js new file mode 100644 index 00000000000..b555e57151a --- /dev/null +++ b/web_iconify/static/lib/iconify/iconify-icon.js @@ -0,0 +1,2301 @@ +/** +* (c) Iconify +* +* For the full copyright and license information, please view the license.txt +* files at https://github.com/iconify/iconify +* +* Licensed under MIT. +* +* @license MIT +* @version 2.3.0 +*/ +(function () { + 'use strict'; + + const defaultIconDimensions = Object.freeze( + { + left: 0, + top: 0, + width: 16, + height: 16 + } + ); + const defaultIconTransformations = Object.freeze({ + rotate: 0, + vFlip: false, + hFlip: false + }); + const defaultIconProps = Object.freeze({ + ...defaultIconDimensions, + ...defaultIconTransformations + }); + const defaultExtendedIconProps = Object.freeze({ + ...defaultIconProps, + body: "", + hidden: false + }); + + const defaultIconSizeCustomisations = Object.freeze({ + width: null, + height: null + }); + const defaultIconCustomisations = Object.freeze({ + // Dimensions + ...defaultIconSizeCustomisations, + // Transformations + ...defaultIconTransformations + }); + + function rotateFromString(value, defaultValue = 0) { + const units = value.replace(/^-?[0-9.]*/, ""); + function cleanup(value2) { + while (value2 < 0) { + value2 += 4; + } + return value2 % 4; + } + if (units === "") { + const num = parseInt(value); + return isNaN(num) ? 0 : cleanup(num); + } else if (units !== value) { + let split = 0; + switch (units) { + case "%": + split = 25; + break; + case "deg": + split = 90; + } + if (split) { + let num = parseFloat(value.slice(0, value.length - units.length)); + if (isNaN(num)) { + return 0; + } + num = num / split; + return num % 1 === 0 ? cleanup(num) : 0; + } + } + return defaultValue; + } + + const separator = /[\s,]+/; + function flipFromString(custom, flip) { + flip.split(separator).forEach((str) => { + const value = str.trim(); + switch (value) { + case "horizontal": + custom.hFlip = true; + break; + case "vertical": + custom.vFlip = true; + break; + } + }); + } + + const defaultCustomisations = { + ...defaultIconCustomisations, + preserveAspectRatio: '', + }; + /** + * Get customisations + */ + function getCustomisations(node) { + const customisations = { + ...defaultCustomisations, + }; + const attr = (key, def) => node.getAttribute(key) || def; + // Dimensions + customisations.width = attr('width', null); + customisations.height = attr('height', null); + // Rotation + customisations.rotate = rotateFromString(attr('rotate', '')); + // Flip + flipFromString(customisations, attr('flip', '')); + // SVG attributes + customisations.preserveAspectRatio = attr('preserveAspectRatio', attr('preserveaspectratio', '')); + return customisations; + } + /** + * Check if customisations have been updated + */ + function haveCustomisationsChanged(value1, value2) { + for (const key in defaultCustomisations) { + if (value1[key] !== value2[key]) { + return true; + } + } + return false; + } + + const matchIconName = /^[a-z0-9]+(-[a-z0-9]+)*$/; + const stringToIcon = (value, validate, allowSimpleName, provider = "") => { + const colonSeparated = value.split(":"); + if (value.slice(0, 1) === "@") { + if (colonSeparated.length < 2 || colonSeparated.length > 3) { + return null; + } + provider = colonSeparated.shift().slice(1); + } + if (colonSeparated.length > 3 || !colonSeparated.length) { + return null; + } + if (colonSeparated.length > 1) { + const name2 = colonSeparated.pop(); + const prefix = colonSeparated.pop(); + const result = { + // Allow provider without '@': "provider:prefix:name" + provider: colonSeparated.length > 0 ? colonSeparated[0] : provider, + prefix, + name: name2 + }; + return validate && !validateIconName(result) ? null : result; + } + const name = colonSeparated[0]; + const dashSeparated = name.split("-"); + if (dashSeparated.length > 1) { + const result = { + provider, + prefix: dashSeparated.shift(), + name: dashSeparated.join("-") + }; + return validate && !validateIconName(result) ? null : result; + } + if (allowSimpleName && provider === "") { + const result = { + provider, + prefix: "", + name + }; + return validate && !validateIconName(result, allowSimpleName) ? null : result; + } + return null; + }; + const validateIconName = (icon, allowSimpleName) => { + if (!icon) { + return false; + } + return !!// Check prefix: cannot be empty, unless allowSimpleName is enabled + // Check name: cannot be empty + ((allowSimpleName && icon.prefix === "" || !!icon.prefix) && !!icon.name); + }; + + function mergeIconTransformations(obj1, obj2) { + const result = {}; + if (!obj1.hFlip !== !obj2.hFlip) { + result.hFlip = true; + } + if (!obj1.vFlip !== !obj2.vFlip) { + result.vFlip = true; + } + const rotate = ((obj1.rotate || 0) + (obj2.rotate || 0)) % 4; + if (rotate) { + result.rotate = rotate; + } + return result; + } + + function mergeIconData(parent, child) { + const result = mergeIconTransformations(parent, child); + for (const key in defaultExtendedIconProps) { + if (key in defaultIconTransformations) { + if (key in parent && !(key in result)) { + result[key] = defaultIconTransformations[key]; + } + } else if (key in child) { + result[key] = child[key]; + } else if (key in parent) { + result[key] = parent[key]; + } + } + return result; + } + + function getIconsTree(data, names) { + const icons = data.icons; + const aliases = data.aliases || /* @__PURE__ */ Object.create(null); + const resolved = /* @__PURE__ */ Object.create(null); + function resolve(name) { + if (icons[name]) { + return resolved[name] = []; + } + if (!(name in resolved)) { + resolved[name] = null; + const parent = aliases[name] && aliases[name].parent; + const value = parent && resolve(parent); + if (value) { + resolved[name] = [parent].concat(value); + } + } + return resolved[name]; + } + (Object.keys(icons).concat(Object.keys(aliases))).forEach(resolve); + return resolved; + } + + function internalGetIconData(data, name, tree) { + const icons = data.icons; + const aliases = data.aliases || /* @__PURE__ */ Object.create(null); + let currentProps = {}; + function parse(name2) { + currentProps = mergeIconData( + icons[name2] || aliases[name2], + currentProps + ); + } + parse(name); + tree.forEach(parse); + return mergeIconData(data, currentProps); + } + + function parseIconSet(data, callback) { + const names = []; + if (typeof data !== "object" || typeof data.icons !== "object") { + return names; + } + if (data.not_found instanceof Array) { + data.not_found.forEach((name) => { + callback(name, null); + names.push(name); + }); + } + const tree = getIconsTree(data); + for (const name in tree) { + const item = tree[name]; + if (item) { + callback(name, internalGetIconData(data, name, item)); + names.push(name); + } + } + return names; + } + + const optionalPropertyDefaults = { + provider: "", + aliases: {}, + not_found: {}, + ...defaultIconDimensions + }; + function checkOptionalProps(item, defaults) { + for (const prop in defaults) { + if (prop in item && typeof item[prop] !== typeof defaults[prop]) { + return false; + } + } + return true; + } + function quicklyValidateIconSet(obj) { + if (typeof obj !== "object" || obj === null) { + return null; + } + const data = obj; + if (typeof data.prefix !== "string" || !obj.icons || typeof obj.icons !== "object") { + return null; + } + if (!checkOptionalProps(obj, optionalPropertyDefaults)) { + return null; + } + const icons = data.icons; + for (const name in icons) { + const icon = icons[name]; + if ( + // Name cannot be empty + !name || // Must have body + typeof icon.body !== "string" || // Check other props + !checkOptionalProps( + icon, + defaultExtendedIconProps + ) + ) { + return null; + } + } + const aliases = data.aliases || /* @__PURE__ */ Object.create(null); + for (const name in aliases) { + const icon = aliases[name]; + const parent = icon.parent; + if ( + // Name cannot be empty + !name || // Parent must be set and point to existing icon + typeof parent !== "string" || !icons[parent] && !aliases[parent] || // Check other props + !checkOptionalProps( + icon, + defaultExtendedIconProps + ) + ) { + return null; + } + } + return data; + } + + const dataStorage = /* @__PURE__ */ Object.create(null); + function newStorage(provider, prefix) { + return { + provider, + prefix, + icons: /* @__PURE__ */ Object.create(null), + missing: /* @__PURE__ */ new Set() + }; + } + function getStorage(provider, prefix) { + const providerStorage = dataStorage[provider] || (dataStorage[provider] = /* @__PURE__ */ Object.create(null)); + return providerStorage[prefix] || (providerStorage[prefix] = newStorage(provider, prefix)); + } + function addIconSet(storage, data) { + if (!quicklyValidateIconSet(data)) { + return []; + } + return parseIconSet(data, (name, icon) => { + if (icon) { + storage.icons[name] = icon; + } else { + storage.missing.add(name); + } + }); + } + function addIconToStorage(storage, name, icon) { + try { + if (typeof icon.body === "string") { + storage.icons[name] = { ...icon }; + return true; + } + } catch (err) { + } + return false; + } + function listIcons(provider, prefix) { + let allIcons = []; + const providers = typeof provider === "string" ? [provider] : Object.keys(dataStorage); + providers.forEach((provider2) => { + const prefixes = typeof provider2 === "string" && typeof prefix === "string" ? [prefix] : Object.keys(dataStorage[provider2] || {}); + prefixes.forEach((prefix2) => { + const storage = getStorage(provider2, prefix2); + allIcons = allIcons.concat( + Object.keys(storage.icons).map( + (name) => (provider2 !== "" ? "@" + provider2 + ":" : "") + prefix2 + ":" + name + ) + ); + }); + }); + return allIcons; + } + + let simpleNames = false; + function allowSimpleNames(allow) { + if (typeof allow === "boolean") { + simpleNames = allow; + } + return simpleNames; + } + function getIconData(name) { + const icon = typeof name === "string" ? stringToIcon(name, true, simpleNames) : name; + if (icon) { + const storage = getStorage(icon.provider, icon.prefix); + const iconName = icon.name; + return storage.icons[iconName] || (storage.missing.has(iconName) ? null : void 0); + } + } + function addIcon(name, data) { + const icon = stringToIcon(name, true, simpleNames); + if (!icon) { + return false; + } + const storage = getStorage(icon.provider, icon.prefix); + if (data) { + return addIconToStorage(storage, icon.name, data); + } else { + storage.missing.add(icon.name); + return true; + } + } + function addCollection(data, provider) { + if (typeof data !== "object") { + return false; + } + if (typeof provider !== "string") { + provider = data.provider || ""; + } + if (simpleNames && !provider && !data.prefix) { + let added = false; + if (quicklyValidateIconSet(data)) { + data.prefix = ""; + parseIconSet(data, (name, icon) => { + if (addIcon(name, icon)) { + added = true; + } + }); + } + return added; + } + const prefix = data.prefix; + if (!validateIconName({ + provider, + prefix, + name: "a" + })) { + return false; + } + const storage = getStorage(provider, prefix); + return !!addIconSet(storage, data); + } + function iconLoaded(name) { + return !!getIconData(name); + } + function getIcon(name) { + const result = getIconData(name); + return result ? { + ...defaultIconProps, + ...result + } : result; + } + + function sortIcons(icons) { + const result = { + loaded: [], + missing: [], + pending: [] + }; + const storage = /* @__PURE__ */ Object.create(null); + icons.sort((a, b) => { + if (a.provider !== b.provider) { + return a.provider.localeCompare(b.provider); + } + if (a.prefix !== b.prefix) { + return a.prefix.localeCompare(b.prefix); + } + return a.name.localeCompare(b.name); + }); + let lastIcon = { + provider: "", + prefix: "", + name: "" + }; + icons.forEach((icon) => { + if (lastIcon.name === icon.name && lastIcon.prefix === icon.prefix && lastIcon.provider === icon.provider) { + return; + } + lastIcon = icon; + const provider = icon.provider; + const prefix = icon.prefix; + const name = icon.name; + const providerStorage = storage[provider] || (storage[provider] = /* @__PURE__ */ Object.create(null)); + const localStorage = providerStorage[prefix] || (providerStorage[prefix] = getStorage(provider, prefix)); + let list; + if (name in localStorage.icons) { + list = result.loaded; + } else if (prefix === "" || localStorage.missing.has(name)) { + list = result.missing; + } else { + list = result.pending; + } + const item = { + provider, + prefix, + name + }; + list.push(item); + }); + return result; + } + + function removeCallback(storages, id) { + storages.forEach((storage) => { + const items = storage.loaderCallbacks; + if (items) { + storage.loaderCallbacks = items.filter((row) => row.id !== id); + } + }); + } + function updateCallbacks(storage) { + if (!storage.pendingCallbacksFlag) { + storage.pendingCallbacksFlag = true; + setTimeout(() => { + storage.pendingCallbacksFlag = false; + const items = storage.loaderCallbacks ? storage.loaderCallbacks.slice(0) : []; + if (!items.length) { + return; + } + let hasPending = false; + const provider = storage.provider; + const prefix = storage.prefix; + items.forEach((item) => { + const icons = item.icons; + const oldLength = icons.pending.length; + icons.pending = icons.pending.filter((icon) => { + if (icon.prefix !== prefix) { + return true; + } + const name = icon.name; + if (storage.icons[name]) { + icons.loaded.push({ + provider, + prefix, + name + }); + } else if (storage.missing.has(name)) { + icons.missing.push({ + provider, + prefix, + name + }); + } else { + hasPending = true; + return true; + } + return false; + }); + if (icons.pending.length !== oldLength) { + if (!hasPending) { + removeCallback([storage], item.id); + } + item.callback( + icons.loaded.slice(0), + icons.missing.slice(0), + icons.pending.slice(0), + item.abort + ); + } + }); + }); + } + } + let idCounter = 0; + function storeCallback(callback, icons, pendingSources) { + const id = idCounter++; + const abort = removeCallback.bind(null, pendingSources, id); + if (!icons.pending.length) { + return abort; + } + const item = { + id, + icons, + callback, + abort + }; + pendingSources.forEach((storage) => { + (storage.loaderCallbacks || (storage.loaderCallbacks = [])).push(item); + }); + return abort; + } + + const storage = /* @__PURE__ */ Object.create(null); + function setAPIModule(provider, item) { + storage[provider] = item; + } + function getAPIModule(provider) { + return storage[provider] || storage[""]; + } + + function listToIcons(list, validate = true, simpleNames = false) { + const result = []; + list.forEach((item) => { + const icon = typeof item === "string" ? stringToIcon(item, validate, simpleNames) : item; + if (icon) { + result.push(icon); + } + }); + return result; + } + + // src/config.ts + var defaultConfig = { + resources: [], + index: 0, + timeout: 2e3, + rotate: 750, + random: false, + dataAfterTimeout: false + }; + + // src/query.ts + function sendQuery(config, payload, query, done) { + const resourcesCount = config.resources.length; + const startIndex = config.random ? Math.floor(Math.random() * resourcesCount) : config.index; + let resources; + if (config.random) { + let list = config.resources.slice(0); + resources = []; + while (list.length > 1) { + const nextIndex = Math.floor(Math.random() * list.length); + resources.push(list[nextIndex]); + list = list.slice(0, nextIndex).concat(list.slice(nextIndex + 1)); + } + resources = resources.concat(list); + } else { + resources = config.resources.slice(startIndex).concat(config.resources.slice(0, startIndex)); + } + const startTime = Date.now(); + let status = "pending"; + let queriesSent = 0; + let lastError; + let timer = null; + let queue = []; + let doneCallbacks = []; + if (typeof done === "function") { + doneCallbacks.push(done); + } + function resetTimer() { + if (timer) { + clearTimeout(timer); + timer = null; + } + } + function abort() { + if (status === "pending") { + status = "aborted"; + } + resetTimer(); + queue.forEach((item) => { + if (item.status === "pending") { + item.status = "aborted"; + } + }); + queue = []; + } + function subscribe(callback, overwrite) { + if (overwrite) { + doneCallbacks = []; + } + if (typeof callback === "function") { + doneCallbacks.push(callback); + } + } + function getQueryStatus() { + return { + startTime, + payload, + status, + queriesSent, + queriesPending: queue.length, + subscribe, + abort + }; + } + function failQuery() { + status = "failed"; + doneCallbacks.forEach((callback) => { + callback(void 0, lastError); + }); + } + function clearQueue() { + queue.forEach((item) => { + if (item.status === "pending") { + item.status = "aborted"; + } + }); + queue = []; + } + function moduleResponse(item, response, data) { + const isError = response !== "success"; + queue = queue.filter((queued) => queued !== item); + switch (status) { + case "pending": + break; + case "failed": + if (isError || !config.dataAfterTimeout) { + return; + } + break; + default: + return; + } + if (response === "abort") { + lastError = data; + failQuery(); + return; + } + if (isError) { + lastError = data; + if (!queue.length) { + if (!resources.length) { + failQuery(); + } else { + execNext(); + } + } + return; + } + resetTimer(); + clearQueue(); + if (!config.random) { + const index = config.resources.indexOf(item.resource); + if (index !== -1 && index !== config.index) { + config.index = index; + } + } + status = "completed"; + doneCallbacks.forEach((callback) => { + callback(data); + }); + } + function execNext() { + if (status !== "pending") { + return; + } + resetTimer(); + const resource = resources.shift(); + if (resource === void 0) { + if (queue.length) { + timer = setTimeout(() => { + resetTimer(); + if (status === "pending") { + clearQueue(); + failQuery(); + } + }, config.timeout); + return; + } + failQuery(); + return; + } + const item = { + status: "pending", + resource, + callback: (status2, data) => { + moduleResponse(item, status2, data); + } + }; + queue.push(item); + queriesSent++; + timer = setTimeout(execNext, config.rotate); + query(resource, payload, item.callback); + } + setTimeout(execNext); + return getQueryStatus; + } + + // src/index.ts + function initRedundancy(cfg) { + const config = { + ...defaultConfig, + ...cfg + }; + let queries = []; + function cleanup() { + queries = queries.filter((item) => item().status === "pending"); + } + function query(payload, queryCallback, doneCallback) { + const query2 = sendQuery( + config, + payload, + queryCallback, + (data, error) => { + cleanup(); + if (doneCallback) { + doneCallback(data, error); + } + } + ); + queries.push(query2); + return query2; + } + function find(callback) { + return queries.find((value) => { + return callback(value); + }) || null; + } + const instance = { + query, + find, + setIndex: (index) => { + config.index = index; + }, + getIndex: () => config.index, + cleanup + }; + return instance; + } + + function createAPIConfig(source) { + let resources; + if (typeof source.resources === "string") { + resources = [source.resources]; + } else { + resources = source.resources; + if (!(resources instanceof Array) || !resources.length) { + return null; + } + } + const result = { + // API hosts + resources, + // Root path + path: source.path || "/", + // URL length limit + maxURL: source.maxURL || 500, + // Timeout before next host is used. + rotate: source.rotate || 750, + // Timeout before failing query. + timeout: source.timeout || 5e3, + // Randomise default API end point. + random: source.random === true, + // Start index + index: source.index || 0, + // Receive data after time out (used if time out kicks in first, then API module sends data anyway). + dataAfterTimeout: source.dataAfterTimeout !== false + }; + return result; + } + const configStorage = /* @__PURE__ */ Object.create(null); + const fallBackAPISources = [ + "https://api.simplesvg.com", + "https://api.unisvg.com" + ]; + const fallBackAPI = []; + while (fallBackAPISources.length > 0) { + if (fallBackAPISources.length === 1) { + fallBackAPI.push(fallBackAPISources.shift()); + } else { + if (Math.random() > 0.5) { + fallBackAPI.push(fallBackAPISources.shift()); + } else { + fallBackAPI.push(fallBackAPISources.pop()); + } + } + } + configStorage[""] = createAPIConfig({ + resources: ["https://api.iconify.design"].concat(fallBackAPI) + }); + function addAPIProvider(provider, customConfig) { + const config = createAPIConfig(customConfig); + if (config === null) { + return false; + } + configStorage[provider] = config; + return true; + } + function getAPIConfig(provider) { + return configStorage[provider]; + } + function listAPIProviders() { + return Object.keys(configStorage); + } + + function emptyCallback$1() { + } + const redundancyCache = /* @__PURE__ */ Object.create(null); + function getRedundancyCache(provider) { + if (!redundancyCache[provider]) { + const config = getAPIConfig(provider); + if (!config) { + return; + } + const redundancy = initRedundancy(config); + const cachedReundancy = { + config, + redundancy + }; + redundancyCache[provider] = cachedReundancy; + } + return redundancyCache[provider]; + } + function sendAPIQuery(target, query, callback) { + let redundancy; + let send; + if (typeof target === "string") { + const api = getAPIModule(target); + if (!api) { + callback(void 0, 424); + return emptyCallback$1; + } + send = api.send; + const cached = getRedundancyCache(target); + if (cached) { + redundancy = cached.redundancy; + } + } else { + const config = createAPIConfig(target); + if (config) { + redundancy = initRedundancy(config); + const moduleKey = target.resources ? target.resources[0] : ""; + const api = getAPIModule(moduleKey); + if (api) { + send = api.send; + } + } + } + if (!redundancy || !send) { + callback(void 0, 424); + return emptyCallback$1; + } + return redundancy.query(query, send, callback)().abort; + } + + function emptyCallback() { + } + function loadedNewIcons(storage) { + if (!storage.iconsLoaderFlag) { + storage.iconsLoaderFlag = true; + setTimeout(() => { + storage.iconsLoaderFlag = false; + updateCallbacks(storage); + }); + } + } + function checkIconNamesForAPI(icons) { + const valid = []; + const invalid = []; + icons.forEach((name) => { + (name.match(matchIconName) ? valid : invalid).push(name); + }); + return { + valid, + invalid + }; + } + function parseLoaderResponse(storage, icons, data) { + function checkMissing() { + const pending = storage.pendingIcons; + icons.forEach((name) => { + if (pending) { + pending.delete(name); + } + if (!storage.icons[name]) { + storage.missing.add(name); + } + }); + } + if (data && typeof data === "object") { + try { + const parsed = addIconSet(storage, data); + if (!parsed.length) { + checkMissing(); + return; + } + } catch (err) { + console.error(err); + } + } + checkMissing(); + loadedNewIcons(storage); + } + function parsePossiblyAsyncResponse(response, callback) { + if (response instanceof Promise) { + response.then((data) => { + callback(data); + }).catch(() => { + callback(null); + }); + } else { + callback(response); + } + } + function loadNewIcons(storage, icons) { + if (!storage.iconsToLoad) { + storage.iconsToLoad = icons; + } else { + storage.iconsToLoad = storage.iconsToLoad.concat(icons).sort(); + } + if (!storage.iconsQueueFlag) { + storage.iconsQueueFlag = true; + setTimeout(() => { + storage.iconsQueueFlag = false; + const { provider, prefix } = storage; + const icons2 = storage.iconsToLoad; + delete storage.iconsToLoad; + if (!icons2 || !icons2.length) { + return; + } + const customIconLoader = storage.loadIcon; + if (storage.loadIcons && (icons2.length > 1 || !customIconLoader)) { + parsePossiblyAsyncResponse( + storage.loadIcons(icons2, prefix, provider), + (data) => { + parseLoaderResponse(storage, icons2, data); + } + ); + return; + } + if (customIconLoader) { + icons2.forEach((name) => { + const response = customIconLoader(name, prefix, provider); + parsePossiblyAsyncResponse(response, (data) => { + const iconSet = data ? { + prefix, + icons: { + [name]: data + } + } : null; + parseLoaderResponse(storage, [name], iconSet); + }); + }); + return; + } + const { valid, invalid } = checkIconNamesForAPI(icons2); + if (invalid.length) { + parseLoaderResponse(storage, invalid, null); + } + if (!valid.length) { + return; + } + const api = prefix.match(matchIconName) ? getAPIModule(provider) : null; + if (!api) { + parseLoaderResponse(storage, valid, null); + return; + } + const params = api.prepare(provider, prefix, valid); + params.forEach((item) => { + sendAPIQuery(provider, item, (data) => { + parseLoaderResponse(storage, item.icons, data); + }); + }); + }); + } + } + const loadIcons = (icons, callback) => { + const cleanedIcons = listToIcons(icons, true, allowSimpleNames()); + const sortedIcons = sortIcons(cleanedIcons); + if (!sortedIcons.pending.length) { + let callCallback = true; + if (callback) { + setTimeout(() => { + if (callCallback) { + callback( + sortedIcons.loaded, + sortedIcons.missing, + sortedIcons.pending, + emptyCallback + ); + } + }); + } + return () => { + callCallback = false; + }; + } + const newIcons = /* @__PURE__ */ Object.create(null); + const sources = []; + let lastProvider, lastPrefix; + sortedIcons.pending.forEach((icon) => { + const { provider, prefix } = icon; + if (prefix === lastPrefix && provider === lastProvider) { + return; + } + lastProvider = provider; + lastPrefix = prefix; + sources.push(getStorage(provider, prefix)); + const providerNewIcons = newIcons[provider] || (newIcons[provider] = /* @__PURE__ */ Object.create(null)); + if (!providerNewIcons[prefix]) { + providerNewIcons[prefix] = []; + } + }); + sortedIcons.pending.forEach((icon) => { + const { provider, prefix, name } = icon; + const storage = getStorage(provider, prefix); + const pendingQueue = storage.pendingIcons || (storage.pendingIcons = /* @__PURE__ */ new Set()); + if (!pendingQueue.has(name)) { + pendingQueue.add(name); + newIcons[provider][prefix].push(name); + } + }); + sources.forEach((storage) => { + const list = newIcons[storage.provider][storage.prefix]; + if (list.length) { + loadNewIcons(storage, list); + } + }); + return callback ? storeCallback(callback, sortedIcons, sources) : emptyCallback; + }; + const loadIcon = (icon) => { + return new Promise((fulfill, reject) => { + const iconObj = typeof icon === "string" ? stringToIcon(icon, true) : icon; + if (!iconObj) { + reject(icon); + return; + } + loadIcons([iconObj || icon], (loaded) => { + if (loaded.length && iconObj) { + const data = getIconData(iconObj); + if (data) { + fulfill({ + ...defaultIconProps, + ...data + }); + return; + } + } + reject(icon); + }); + }); + }; + + /** + * Test icon string + */ + function testIconObject(value) { + try { + const obj = typeof value === 'string' ? JSON.parse(value) : value; + if (typeof obj.body === 'string') { + return { + ...obj, + }; + } + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } + catch (err) { + // + } + } + + /** + * Parse icon value, load if needed + */ + function parseIconValue(value, onload) { + if (typeof value === 'object') { + const data = testIconObject(value); + return { + data, + value, + }; + } + if (typeof value !== 'string') { + // Invalid value + return { + value, + }; + } + // Check for JSON + if (value.includes('{')) { + const data = testIconObject(value); + if (data) { + return { + data, + value, + }; + } + } + // Parse icon name + const name = stringToIcon(value, true, true); + if (!name) { + return { + value, + }; + } + // Valid icon name: check if data is available + const data = getIconData(name); + // Icon data exists or icon has no prefix. Do not load icon from API if icon has no prefix + if (data !== undefined || !name.prefix) { + return { + value, + name, + data, // could be 'null' -> icon is missing + }; + } + // Load icon + const loading = loadIcons([name], () => onload(value, name, getIconData(name))); + return { + value, + name, + loading, + }; + } + + // Check for Safari + let isBuggedSafari = false; + try { + isBuggedSafari = navigator.vendor.indexOf('Apple') === 0; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } + catch (err) { + // + } + /** + * Get render mode + */ + function getRenderMode(body, mode) { + switch (mode) { + // Force mode + case 'svg': + case 'bg': + case 'mask': + return mode; + } + // Check for animation, use 'style' for animated icons, unless browser is Safari + // (only , which should be ignored or animations start with ' + return 'svg'; + } + // Use background or mask + return body.indexOf('currentColor') === -1 ? 'bg' : 'mask'; + } + + const unitsSplit = /(-?[0-9.]*[0-9]+[0-9.]*)/g; + const unitsTest = /^-?[0-9.]*[0-9]+[0-9.]*$/g; + function calculateSize(size, ratio, precision) { + if (ratio === 1) { + return size; + } + precision = precision || 100; + if (typeof size === "number") { + return Math.ceil(size * ratio * precision) / precision; + } + if (typeof size !== "string") { + return size; + } + const oldParts = size.split(unitsSplit); + if (oldParts === null || !oldParts.length) { + return size; + } + const newParts = []; + let code = oldParts.shift(); + let isNumber = unitsTest.test(code); + while (true) { + if (isNumber) { + const num = parseFloat(code); + if (isNaN(num)) { + newParts.push(code); + } else { + newParts.push(Math.ceil(num * ratio * precision) / precision); + } + } else { + newParts.push(code); + } + code = oldParts.shift(); + if (code === void 0) { + return newParts.join(""); + } + isNumber = !isNumber; + } + } + + function splitSVGDefs(content, tag = "defs") { + let defs = ""; + const index = content.indexOf("<" + tag); + while (index >= 0) { + const start = content.indexOf(">", index); + const end = content.indexOf("", end); + if (endEnd === -1) { + break; + } + defs += content.slice(start + 1, end).trim(); + content = content.slice(0, index).trim() + content.slice(endEnd + 1); + } + return { + defs, + content + }; + } + function mergeDefsAndContent(defs, content) { + return defs ? "" + defs + "" + content : content; + } + function wrapSVGContent(body, start, end) { + const split = splitSVGDefs(body); + return mergeDefsAndContent(split.defs, start + split.content + end); + } + + const isUnsetKeyword = (value) => value === "unset" || value === "undefined" || value === "none"; + function iconToSVG(icon, customisations) { + const fullIcon = { + ...defaultIconProps, + ...icon + }; + const fullCustomisations = { + ...defaultIconCustomisations, + ...customisations + }; + const box = { + left: fullIcon.left, + top: fullIcon.top, + width: fullIcon.width, + height: fullIcon.height + }; + let body = fullIcon.body; + [fullIcon, fullCustomisations].forEach((props) => { + const transformations = []; + const hFlip = props.hFlip; + const vFlip = props.vFlip; + let rotation = props.rotate; + if (hFlip) { + if (vFlip) { + rotation += 2; + } else { + transformations.push( + "translate(" + (box.width + box.left).toString() + " " + (0 - box.top).toString() + ")" + ); + transformations.push("scale(-1 1)"); + box.top = box.left = 0; + } + } else if (vFlip) { + transformations.push( + "translate(" + (0 - box.left).toString() + " " + (box.height + box.top).toString() + ")" + ); + transformations.push("scale(1 -1)"); + box.top = box.left = 0; + } + let tempValue; + if (rotation < 0) { + rotation -= Math.floor(rotation / 4) * 4; + } + rotation = rotation % 4; + switch (rotation) { + case 1: + tempValue = box.height / 2 + box.top; + transformations.unshift( + "rotate(90 " + tempValue.toString() + " " + tempValue.toString() + ")" + ); + break; + case 2: + transformations.unshift( + "rotate(180 " + (box.width / 2 + box.left).toString() + " " + (box.height / 2 + box.top).toString() + ")" + ); + break; + case 3: + tempValue = box.width / 2 + box.left; + transformations.unshift( + "rotate(-90 " + tempValue.toString() + " " + tempValue.toString() + ")" + ); + break; + } + if (rotation % 2 === 1) { + if (box.left !== box.top) { + tempValue = box.left; + box.left = box.top; + box.top = tempValue; + } + if (box.width !== box.height) { + tempValue = box.width; + box.width = box.height; + box.height = tempValue; + } + } + if (transformations.length) { + body = wrapSVGContent( + body, + '', + "" + ); + } + }); + const customisationsWidth = fullCustomisations.width; + const customisationsHeight = fullCustomisations.height; + const boxWidth = box.width; + const boxHeight = box.height; + let width; + let height; + if (customisationsWidth === null) { + height = customisationsHeight === null ? "1em" : customisationsHeight === "auto" ? boxHeight : customisationsHeight; + width = calculateSize(height, boxWidth / boxHeight); + } else { + width = customisationsWidth === "auto" ? boxWidth : customisationsWidth; + height = customisationsHeight === null ? calculateSize(width, boxHeight / boxWidth) : customisationsHeight === "auto" ? boxHeight : customisationsHeight; + } + const attributes = {}; + const setAttr = (prop, value) => { + if (!isUnsetKeyword(value)) { + attributes[prop] = value.toString(); + } + }; + setAttr("width", width); + setAttr("height", height); + const viewBox = [box.left, box.top, boxWidth, boxHeight]; + attributes.viewBox = viewBox.join(" "); + return { + attributes, + viewBox, + body + }; + } + + function iconToHTML(body, attributes) { + let renderAttribsHTML = body.indexOf("xlink:") === -1 ? "" : ' xmlns:xlink="http://www.w3.org/1999/xlink"'; + for (const attr in attributes) { + renderAttribsHTML += " " + attr + '="' + attributes[attr] + '"'; + } + return '" + body + ""; + } + + function encodeSVGforURL(svg) { + return svg.replace(/"/g, "'").replace(/%/g, "%25").replace(/#/g, "%23").replace(//g, "%3E").replace(/\s+/g, " "); + } + function svgToData(svg) { + return "data:image/svg+xml," + encodeSVGforURL(svg); + } + function svgToURL(svg) { + return 'url("' + svgToData(svg) + '")'; + } + + const detectFetch = () => { + let callback; + try { + callback = fetch; + if (typeof callback === "function") { + return callback; + } + } catch (err) { + } + }; + let fetchModule = detectFetch(); + function setFetch(fetch2) { + fetchModule = fetch2; + } + function getFetch() { + return fetchModule; + } + function calculateMaxLength(provider, prefix) { + const config = getAPIConfig(provider); + if (!config) { + return 0; + } + let result; + if (!config.maxURL) { + result = 0; + } else { + let maxHostLength = 0; + config.resources.forEach((item) => { + const host = item; + maxHostLength = Math.max(maxHostLength, host.length); + }); + const url = prefix + ".json?icons="; + result = config.maxURL - maxHostLength - config.path.length - url.length; + } + return result; + } + function shouldAbort(status) { + return status === 404; + } + const prepare = (provider, prefix, icons) => { + const results = []; + const maxLength = calculateMaxLength(provider, prefix); + const type = "icons"; + let item = { + type, + provider, + prefix, + icons: [] + }; + let length = 0; + icons.forEach((name, index) => { + length += name.length + 1; + if (length >= maxLength && index > 0) { + results.push(item); + item = { + type, + provider, + prefix, + icons: [] + }; + length = name.length; + } + item.icons.push(name); + }); + results.push(item); + return results; + }; + function getPath(provider) { + if (typeof provider === "string") { + const config = getAPIConfig(provider); + if (config) { + return config.path; + } + } + return "/"; + } + const send = (host, params, callback) => { + if (!fetchModule) { + callback("abort", 424); + return; + } + let path = getPath(params.provider); + switch (params.type) { + case "icons": { + const prefix = params.prefix; + const icons = params.icons; + const iconsList = icons.join(","); + const urlParams = new URLSearchParams({ + icons: iconsList + }); + path += prefix + ".json?" + urlParams.toString(); + break; + } + case "custom": { + const uri = params.uri; + path += uri.slice(0, 1) === "/" ? uri.slice(1) : uri; + break; + } + default: + callback("abort", 400); + return; + } + let defaultError = 503; + fetchModule(host + path).then((response) => { + const status = response.status; + if (status !== 200) { + setTimeout(() => { + callback(shouldAbort(status) ? "abort" : "next", status); + }); + return; + } + defaultError = 501; + return response.json(); + }).then((data) => { + if (typeof data !== "object" || data === null) { + setTimeout(() => { + if (data === 404) { + callback("abort", data); + } else { + callback("next", defaultError); + } + }); + return; + } + setTimeout(() => { + callback("success", data); + }); + }).catch(() => { + callback("next", defaultError); + }); + }; + const fetchAPIModule = { + prepare, + send + }; + + function setCustomIconsLoader(loader, prefix, provider) { + getStorage(provider || "", prefix).loadIcons = loader; + } + function setCustomIconLoader(loader, prefix, provider) { + getStorage(provider || "", prefix).loadIcon = loader; + } + + /** + * Attribute to add + */ + const nodeAttr = 'data-style'; + /** + * Custom style to add to each node + */ + let customStyle = ''; + /** + * Set custom style to add to all components + * + * Affects only components rendered after function call + */ + function appendCustomStyle(style) { + customStyle = style; + } + /** + * Add/update style node + */ + function updateStyle(parent, inline) { + // Get node, create if needed + let styleNode = Array.from(parent.childNodes).find((node) => node.hasAttribute && + node.hasAttribute(nodeAttr)); + if (!styleNode) { + styleNode = document.createElement('style'); + styleNode.setAttribute(nodeAttr, nodeAttr); + parent.appendChild(styleNode); + } + // Update content + styleNode.textContent = + ':host{display:inline-block;vertical-align:' + + (inline ? '-0.125em' : '0') + + '}span,svg{display:block;margin:auto}' + + customStyle; + } + + // Core + /** + * Get functions and initialise stuff + */ + function exportFunctions() { + /** + * Initialise stuff + */ + // Set API module + setAPIModule('', fetchAPIModule); + // Allow simple icon names + allowSimpleNames(true); + let _window; + try { + _window = window; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } + catch (err) { + // + } + if (_window) { + // Load icons from global "IconifyPreload" + if (_window.IconifyPreload !== void 0) { + const preload = _window.IconifyPreload; + const err = 'Invalid IconifyPreload syntax.'; + if (typeof preload === 'object' && preload !== null) { + (preload instanceof Array ? preload : [preload]).forEach((item) => { + try { + if ( + // Check if item is an object and not null/array + typeof item !== 'object' || + item === null || + item instanceof Array || + // Check for 'icons' and 'prefix' + typeof item.icons !== 'object' || + typeof item.prefix !== 'string' || + // Add icon set + !addCollection(item)) { + console.error(err); + } + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } + catch (e) { + console.error(err); + } + }); + } + } + // Set API from global "IconifyProviders" + if (_window.IconifyProviders !== void 0) { + const providers = _window.IconifyProviders; + if (typeof providers === 'object' && providers !== null) { + for (const key in providers) { + const err = 'IconifyProviders[' + key + '] is invalid.'; + try { + const value = providers[key]; + if (typeof value !== 'object' || + !value || + value.resources === void 0) { + continue; + } + if (!addAPIProvider(key, value)) { + console.error(err); + } + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } + catch (e) { + console.error(err); + } + } + } + } + } + const _api = { + getAPIConfig, + setAPIModule, + sendAPIQuery, + setFetch, + getFetch, + listAPIProviders, + }; + return { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + enableCache: (storage) => { + // No longer used + }, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + disableCache: (storage) => { + // No longer used + }, + iconLoaded, + iconExists: iconLoaded, // deprecated, kept to avoid breaking changes + getIcon, + listIcons, + addIcon, + addCollection, + calculateSize, + buildIcon: iconToSVG, + iconToHTML, + svgToURL, + loadIcons, + loadIcon, + addAPIProvider, + setCustomIconLoader, + setCustomIconsLoader, + appendCustomStyle, + _api, + }; + } + + // List of properties to apply + const monotoneProps = { + 'background-color': 'currentColor', + }; + const coloredProps = { + 'background-color': 'transparent', + }; + // Dynamically add common props to variables above + const propsToAdd = { + image: 'var(--svg)', + repeat: 'no-repeat', + size: '100% 100%', + }; + const propsToAddTo = { + '-webkit-mask': monotoneProps, + 'mask': monotoneProps, + 'background': coloredProps, + }; + for (const prefix in propsToAddTo) { + const list = propsToAddTo[prefix]; + for (const prop in propsToAdd) { + list[prefix + '-' + prop] = propsToAdd[prop]; + } + } + /** + * Fix size: add 'px' to numbers + */ + function fixSize(value) { + return value ? value + (value.match(/^[-0-9.]+$/) ? 'px' : '') : 'inherit'; + } + /** + * Render node as + */ + function renderSPAN(data, icon, useMask) { + const node = document.createElement('span'); + // Body + let body = data.body; + if (body.indexOf(''; + } + // Generate SVG as URL + const renderAttribs = data.attributes; + const html = iconToHTML(body, { + ...renderAttribs, + width: icon.width + '', + height: icon.height + '', + }); + const url = svgToURL(html); + // Generate style + const svgStyle = node.style; + const styles = { + '--svg': url, + 'width': fixSize(renderAttribs.width), + 'height': fixSize(renderAttribs.height), + ...(useMask ? monotoneProps : coloredProps), + }; + // Apply style + for (const prop in styles) { + svgStyle.setProperty(prop, styles[prop]); + } + return node; + } + + let policy; + function createPolicy() { + try { + policy = window.trustedTypes.createPolicy("iconify", { + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + createHTML: (s) => s + }); + } catch (err) { + policy = null; + } + } + function cleanUpInnerHTML(html) { + if (policy === void 0) { + createPolicy(); + } + return policy ? policy.createHTML(html) : html; + } + + /** + * Render node as + */ + function renderSVG(data) { + const node = document.createElement('span'); + // Add style if needed + const attr = data.attributes; + let style = ''; + if (!attr.width) { + style = 'width: inherit;'; + } + if (!attr.height) { + style += 'height: inherit;'; + } + if (style) { + attr.style = style; + } + // Generate SVG + const html = iconToHTML(data.body, attr); + node.innerHTML = cleanUpInnerHTML(html); + return node.firstChild; + } + + /** + * Find icon node + */ + function findIconElement(parent) { + return Array.from(parent.childNodes).find((node) => { + const tag = node.tagName && + node.tagName.toUpperCase(); + return tag === 'SPAN' || tag === 'SVG'; + }); + } + /** + * Render icon + */ + function renderIcon(parent, state) { + const iconData = state.icon.data; + const customisations = state.customisations; + // Render icon + const renderData = iconToSVG(iconData, customisations); + if (customisations.preserveAspectRatio) { + renderData.attributes['preserveAspectRatio'] = + customisations.preserveAspectRatio; + } + const mode = state.renderedMode; + let node; + switch (mode) { + case 'svg': + node = renderSVG(renderData); + break; + default: + node = renderSPAN(renderData, { + ...defaultIconProps, + ...iconData, + }, mode === 'mask'); + } + // Set element + const oldNode = findIconElement(parent); + if (oldNode) { + // Replace old element + if (node.tagName === 'SPAN' && oldNode.tagName === node.tagName) { + // Swap style instead of whole node + oldNode.setAttribute('style', node.getAttribute('style')); + } + else { + parent.replaceChild(node, oldNode); + } + } + else { + // Add new element + parent.appendChild(node); + } + } + + /** + * Set state to PendingState + */ + function setPendingState(icon, inline, lastState) { + const lastRender = lastState && + (lastState.rendered + ? lastState + : lastState.lastRender); + return { + rendered: false, + inline, + icon, + lastRender, + }; + } + + /** + * Register 'iconify-icon' component, if it does not exist + */ + function defineIconifyIcon(name = 'iconify-icon') { + // Check for custom elements registry and HTMLElement + let customElements; + let ParentClass; + try { + customElements = window.customElements; + ParentClass = window.HTMLElement; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } + catch (err) { + return; + } + // Make sure registry and HTMLElement exist + if (!customElements || !ParentClass) { + return; + } + // Check for duplicate + const ConflictingClass = customElements.get(name); + if (ConflictingClass) { + return ConflictingClass; + } + // All attributes + const attributes = [ + // Icon + 'icon', + // Mode + 'mode', + 'inline', + 'noobserver', + // Customisations + 'width', + 'height', + 'rotate', + 'flip', + ]; + /** + * Component class + */ + const IconifyIcon = class extends ParentClass { + // Root + _shadowRoot; + // Initialised + _initialised = false; + // Icon state + _state; + // Attributes check queued + _checkQueued = false; + // Connected + _connected = false; + // Observer + _observer = null; + _visible = true; + /** + * Constructor + */ + constructor() { + super(); + // Attach shadow DOM + const root = (this._shadowRoot = this.attachShadow({ + mode: 'open', + })); + // Add style + const inline = this.hasAttribute('inline'); + updateStyle(root, inline); + // Create empty state + this._state = setPendingState({ + value: '', + }, inline); + // Queue icon render + this._queueCheck(); + } + /** + * Connected to DOM + */ + connectedCallback() { + this._connected = true; + this.startObserver(); + } + /** + * Disconnected from DOM + */ + disconnectedCallback() { + this._connected = false; + this.stopObserver(); + } + /** + * Observed attributes + */ + static get observedAttributes() { + return attributes.slice(0); + } + /** + * Observed properties that are different from attributes + * + * Experimental! Need to test with various frameworks that support it + */ + /* + static get properties() { + return { + inline: { + type: Boolean, + reflect: true, + }, + // Not listing other attributes because they are strings or combination + // of string and another type. Cannot have multiple types + }; + } + */ + /** + * Attribute has changed + */ + attributeChangedCallback(name) { + switch (name) { + case 'inline': { + // Update immediately: not affected by other attributes + const newInline = this.hasAttribute('inline'); + const state = this._state; + if (newInline !== state.inline) { + // Update style if inline mode changed + state.inline = newInline; + updateStyle(this._shadowRoot, newInline); + } + break; + } + case 'noobserver': { + const value = this.hasAttribute('noobserver'); + if (value) { + this.startObserver(); + } + else { + this.stopObserver(); + } + break; + } + default: + // Queue check for other attributes + this._queueCheck(); + } + } + /** + * Get/set icon + */ + get icon() { + const value = this.getAttribute('icon'); + if (value && value.slice(0, 1) === '{') { + try { + return JSON.parse(value); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } + catch (err) { + // + } + } + return value; + } + set icon(value) { + if (typeof value === 'object') { + value = JSON.stringify(value); + } + this.setAttribute('icon', value); + } + /** + * Get/set inline + */ + get inline() { + return this.hasAttribute('inline'); + } + set inline(value) { + if (value) { + this.setAttribute('inline', 'true'); + } + else { + this.removeAttribute('inline'); + } + } + /** + * Get/set observer + */ + get observer() { + return this.hasAttribute('observer'); + } + set observer(value) { + if (value) { + this.setAttribute('observer', 'true'); + } + else { + this.removeAttribute('observer'); + } + } + /** + * Restart animation + */ + restartAnimation() { + const state = this._state; + if (state.rendered) { + const root = this._shadowRoot; + if (state.renderedMode === 'svg') { + // Update root node + try { + root.lastChild.setCurrentTime(0); + return; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } + catch (err) { + // Failed: setCurrentTime() is not supported + } + } + renderIcon(root, state); + } + } + /** + * Get status + */ + get status() { + const state = this._state; + return state.rendered + ? 'rendered' + : state.icon.data === null + ? 'failed' + : 'loading'; + } + /** + * Queue attributes re-check + */ + _queueCheck() { + if (!this._checkQueued) { + this._checkQueued = true; + setTimeout(() => { + this._check(); + }); + } + } + /** + * Check for changes + */ + _check() { + if (!this._checkQueued) { + return; + } + this._checkQueued = false; + const state = this._state; + // Get icon + const newIcon = this.getAttribute('icon'); + if (newIcon !== state.icon.value) { + this._iconChanged(newIcon); + return; + } + // Ignore other attributes if icon is not rendered + if (!state.rendered || !this._visible) { + return; + } + // Check for mode and attribute changes + const mode = this.getAttribute('mode'); + const customisations = getCustomisations(this); + if (state.attrMode !== mode || + haveCustomisationsChanged(state.customisations, customisations) || + !findIconElement(this._shadowRoot)) { + this._renderIcon(state.icon, customisations, mode); + } + } + /** + * Icon value has changed + */ + _iconChanged(newValue) { + const icon = parseIconValue(newValue, (value, name, data) => { + // Asynchronous callback: re-check values to make sure stuff wasn't changed + const state = this._state; + if (state.rendered || this.getAttribute('icon') !== value) { + // Icon data is already available or icon attribute was changed + return; + } + // Change icon + const icon = { + value, + name, + data, + }; + if (icon.data) { + // Render icon + this._gotIconData(icon); + } + else { + // Nothing to render: update icon in state + state.icon = icon; + } + }); + if (icon.data) { + // Icon is ready to render + this._gotIconData(icon); + } + else { + // Pending icon + this._state = setPendingState(icon, this._state.inline, this._state); + } + } + /** + * Force render icon on state change + */ + _forceRender() { + if (!this._visible) { + // Remove icon + const node = findIconElement(this._shadowRoot); + if (node) { + this._shadowRoot.removeChild(node); + } + return; + } + // Re-render icon + this._queueCheck(); + } + /** + * Got new icon data, icon is ready to (re)render + */ + _gotIconData(icon) { + this._checkQueued = false; + this._renderIcon(icon, getCustomisations(this), this.getAttribute('mode')); + } + /** + * Re-render based on icon data + */ + _renderIcon(icon, customisations, attrMode) { + // Get mode + const renderedMode = getRenderMode(icon.data.body, attrMode); + // Inline was not changed + const inline = this._state.inline; + // Set state and render + renderIcon(this._shadowRoot, (this._state = { + rendered: true, + icon, + inline, + customisations, + attrMode, + renderedMode, + })); + } + /** + * Start observer + */ + startObserver() { + if (!this._observer && !this.hasAttribute('noobserver')) { + try { + this._observer = new IntersectionObserver((entries) => { + const intersecting = entries.some((entry) => entry.isIntersecting); + if (intersecting !== this._visible) { + this._visible = intersecting; + this._forceRender(); + } + }); + this._observer.observe(this); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } + catch (err) { + // Something went wrong, possibly observer is not supported + if (this._observer) { + try { + this._observer.disconnect(); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } + catch (err) { + // + } + this._observer = null; + } + } + } + } + /** + * Stop observer + */ + stopObserver() { + if (this._observer) { + this._observer.disconnect(); + this._observer = null; + this._visible = true; + if (this._connected) { + // Render icon + this._forceRender(); + } + } + } + }; + // Add getters and setters + attributes.forEach((attr) => { + if (!(attr in IconifyIcon.prototype)) { + Object.defineProperty(IconifyIcon.prototype, attr, { + get: function () { + return this.getAttribute(attr); + }, + set: function (value) { + if (value !== null) { + this.setAttribute(attr, value); + } + else { + this.removeAttribute(attr); + } + }, + }); + } + }); + // Add exported functions: both as static and instance methods + const functions = exportFunctions(); + for (const key in functions) { + IconifyIcon[key] = IconifyIcon.prototype[key] = functions[key]; + } + // Define new component + customElements.define(name, IconifyIcon); + return IconifyIcon; + } + + // Register component + defineIconifyIcon(); + +})(); diff --git a/web_iconify/static/lib/iconify/iconify-icon.min.js b/web_iconify/static/lib/iconify/iconify-icon.min.js new file mode 100644 index 00000000000..712364e3788 --- /dev/null +++ b/web_iconify/static/lib/iconify/iconify-icon.min.js @@ -0,0 +1,12 @@ +/** +* (c) Iconify +* +* For the full copyright and license information, please view the license.txt +* files at https://github.com/iconify/iconify +* +* Licensed under MIT. +* +* @license MIT +* @version 2.3.0 +*/ +!function(){"use strict";const t=Object.freeze({left:0,top:0,width:16,height:16}),e=Object.freeze({rotate:0,vFlip:!1,hFlip:!1}),n=Object.freeze({...t,...e}),i=Object.freeze({...n,body:"",hidden:!1}),r=Object.freeze({width:null,height:null}),o=Object.freeze({...r,...e});const s=/[\s,]+/;const c={...o,preserveAspectRatio:""};function a(t){const e={...c},n=(e,n)=>t.getAttribute(e)||n;var i;return e.width=n("width",null),e.height=n("height",null),e.rotate=function(t,e=0){const n=t.replace(/^-?[0-9.]*/,"");function i(t){for(;t<0;)t+=4;return t%4}if(""===n){const e=parseInt(t);return isNaN(e)?0:i(e)}if(n!==t){let e=0;switch(n){case"%":e=25;break;case"deg":e=90}if(e){let r=parseFloat(t.slice(0,t.length-n.length));return isNaN(r)?0:(r/=e,r%1==0?i(r):0)}}return e}(n("rotate","")),i=e,n("flip","").split(s).forEach((t=>{switch(t.trim()){case"horizontal":i.hFlip=!0;break;case"vertical":i.vFlip=!0}})),e.preserveAspectRatio=n("preserveAspectRatio",n("preserveaspectratio","")),e}const u=/^[a-z0-9]+(-[a-z0-9]+)*$/,l=(t,e,n,i="")=>{const r=t.split(":");if("@"===t.slice(0,1)){if(r.length<2||r.length>3)return null;i=r.shift().slice(1)}if(r.length>3||!r.length)return null;if(r.length>1){const t=r.pop(),n=r.pop(),o={provider:r.length>0?r[0]:i,prefix:n,name:t};return e&&!f(o)?null:o}const o=r[0],s=o.split("-");if(s.length>1){const t={provider:i,prefix:s.shift(),name:s.join("-")};return e&&!f(t)?null:t}if(n&&""===i){const t={provider:i,prefix:"",name:o};return e&&!f(t,n)?null:t}return null},f=(t,e)=>!!t&&!(!(e&&""===t.prefix||t.prefix)||!t.name);function d(t,n){const r=function(t,e){const n={};!t.hFlip!=!e.hFlip&&(n.hFlip=!0),!t.vFlip!=!e.vFlip&&(n.vFlip=!0);const i=((t.rotate||0)+(e.rotate||0))%4;return i&&(n.rotate=i),n}(t,n);for(const o in i)o in e?o in t&&!(o in r)&&(r[o]=e[o]):o in n?r[o]=n[o]:o in t&&(r[o]=t[o]);return r}function h(t,e,n){const i=t.icons,r=t.aliases||Object.create(null);let o={};function s(t){o=d(i[t]||r[t],o)}return s(e),n.forEach(s),d(t,o)}function p(t,e){const n=[];if("object"!=typeof t||"object"!=typeof t.icons)return n;t.not_found instanceof Array&&t.not_found.forEach((t=>{e(t,null),n.push(t)}));const i=function(t,e){const n=t.icons,i=t.aliases||Object.create(null),r=Object.create(null);return Object.keys(n).concat(Object.keys(i)).forEach((function t(e){if(n[e])return r[e]=[];if(!(e in r)){r[e]=null;const n=i[e]&&i[e].parent,o=n&&t(n);o&&(r[e]=[n].concat(o))}return r[e]})),r}(t);for(const r in i){const o=i[r];o&&(e(r,h(t,r,o)),n.push(r))}return n}const g={provider:"",aliases:{},not_found:{},...t};function b(t,e){for(const n in e)if(n in t&&typeof t[n]!=typeof e[n])return!1;return!0}function v(t){if("object"!=typeof t||null===t)return null;const e=t;if("string"!=typeof e.prefix||!t.icons||"object"!=typeof t.icons)return null;if(!b(t,g))return null;const n=e.icons;for(const t in n){const e=n[t];if(!t||"string"!=typeof e.body||!b(e,i))return null}const r=e.aliases||Object.create(null);for(const t in r){const e=r[t],o=e.parent;if(!t||"string"!=typeof o||!n[o]&&!r[o]||!b(e,i))return null}return e}const m=Object.create(null);function y(t,e){const n=m[t]||(m[t]=Object.create(null));return n[e]||(n[e]=function(t,e){return{provider:t,prefix:e,icons:Object.create(null),missing:new Set}}(t,e))}function x(t,e){return v(e)?p(e,((e,n)=>{n?t.icons[e]=n:t.missing.add(e)})):[]}function _(t,e){let n=[];return("string"==typeof t?[t]:Object.keys(m)).forEach((t=>{("string"==typeof t&&"string"==typeof e?[e]:Object.keys(m[t]||{})).forEach((e=>{const i=y(t,e);n=n.concat(Object.keys(i.icons).map((n=>(""!==t?"@"+t+":":"")+e+":"+n)))}))})),n}let w=!1;function k(t){return"boolean"==typeof t&&(w=t),w}function A(t){const e="string"==typeof t?l(t,!0,w):t;if(e){const t=y(e.provider,e.prefix),n=e.name;return t.icons[n]||(t.missing.has(n)?null:void 0)}}function j(t,e){const n=l(t,!0,w);if(!n)return!1;const i=y(n.provider,n.prefix);return e?function(t,e,n){try{if("string"==typeof n.body)return t.icons[e]={...n},!0}catch(t){}return!1}(i,n.name,e):(i.missing.add(n.name),!0)}function O(t,e){if("object"!=typeof t)return!1;if("string"!=typeof e&&(e=t.provider||""),w&&!e&&!t.prefix){let e=!1;return v(t)&&(t.prefix="",p(t,((t,n)=>{j(t,n)&&(e=!0)}))),e}const n=t.prefix;if(!f({provider:e,prefix:n,name:"a"}))return!1;return!!x(y(e,n),t)}function C(t){return!!A(t)}function I(t){const e=A(t);return e?{...n,...e}:e}function E(t,e){t.forEach((t=>{const n=t.loaderCallbacks;n&&(t.loaderCallbacks=n.filter((t=>t.id!==e)))}))}let T=0;const F=Object.create(null);function R(t,e){F[t]=e}function S(t){return F[t]||F[""]}var L={resources:[],index:0,timeout:2e3,rotate:750,random:!1,dataAfterTimeout:!1};function P(t,e,n,i){const r=t.resources.length,o=t.random?Math.floor(Math.random()*r):t.index;let s;if(t.random){let e=t.resources.slice(0);for(s=[];e.length>1;){const t=Math.floor(Math.random()*e.length);s.push(e[t]),e=e.slice(0,t).concat(e.slice(t+1))}s=s.concat(e)}else s=t.resources.slice(o).concat(t.resources.slice(0,o));const c=Date.now();let a,u="pending",l=0,f=null,d=[],h=[];function p(){f&&(clearTimeout(f),f=null)}function g(){"pending"===u&&(u="aborted"),p(),d.forEach((t=>{"pending"===t.status&&(t.status="aborted")})),d=[]}function b(t,e){e&&(h=[]),"function"==typeof t&&h.push(t)}function v(){u="failed",h.forEach((t=>{t(void 0,a)}))}function m(){d.forEach((t=>{"pending"===t.status&&(t.status="aborted")})),d=[]}function y(){if("pending"!==u)return;p();const i=s.shift();if(void 0===i)return d.length?void(f=setTimeout((()=>{p(),"pending"===u&&(m(),v())}),t.timeout)):void v();const r={status:"pending",resource:i,callback:(e,n)=>{!function(e,n,i){const r="success"!==n;switch(d=d.filter((t=>t!==e)),u){case"pending":break;case"failed":if(r||!t.dataAfterTimeout)return;break;default:return}if("abort"===n)return a=i,void v();if(r)return a=i,void(d.length||(s.length?y():v()));if(p(),m(),!t.random){const n=t.resources.indexOf(e.resource);-1!==n&&n!==t.index&&(t.index=n)}u="completed",h.forEach((t=>{t(i)}))}(r,e,n)}};d.push(r),l++,f=setTimeout(y,t.rotate),n(i,e,r.callback)}return"function"==typeof i&&h.push(i),setTimeout(y),function(){return{startTime:c,payload:e,status:u,queriesSent:l,queriesPending:d.length,subscribe:b,abort:g}}}function M(t){const e={...L,...t};let n=[];function i(){n=n.filter((t=>"pending"===t().status))}return{query:function(t,r,o){const s=P(e,t,r,((t,e)=>{i(),o&&o(t,e)}));return n.push(s),s},find:function(t){return n.find((e=>t(e)))||null},setIndex:t=>{e.index=t},getIndex:()=>e.index,cleanup:i}}function N(t){let e;if("string"==typeof t.resources)e=[t.resources];else if(e=t.resources,!(e instanceof Array&&e.length))return null;return{resources:e,path:t.path||"/",maxURL:t.maxURL||500,rotate:t.rotate||750,timeout:t.timeout||5e3,random:!0===t.random,index:t.index||0,dataAfterTimeout:!1!==t.dataAfterTimeout}}const z=Object.create(null),Q=["https://api.simplesvg.com","https://api.unisvg.com"],q=[];for(;Q.length>0;)1===Q.length||Math.random()>.5?q.push(Q.shift()):q.push(Q.pop());function U(t,e){const n=N(e);return null!==n&&(z[t]=n,!0)}function D(t){return z[t]}function H(){return Object.keys(z)}function J(){}z[""]=N({resources:["https://api.iconify.design"].concat(q)});const $=Object.create(null);function B(t,e,n){let i,r;if("string"==typeof t){const e=S(t);if(!e)return n(void 0,424),J;r=e.send;const o=function(t){if(!$[t]){const e=D(t);if(!e)return;const n={config:e,redundancy:M(e)};$[t]=n}return $[t]}(t);o&&(i=o.redundancy)}else{const e=N(t);if(e){i=M(e);const n=S(t.resources?t.resources[0]:"");n&&(r=n.send)}}return i&&r?i.query(e,r,n)().abort:(n(void 0,424),J)}function G(){}function V(t){t.iconsLoaderFlag||(t.iconsLoaderFlag=!0,setTimeout((()=>{t.iconsLoaderFlag=!1,function(t){t.pendingCallbacksFlag||(t.pendingCallbacksFlag=!0,setTimeout((()=>{t.pendingCallbacksFlag=!1;const e=t.loaderCallbacks?t.loaderCallbacks.slice(0):[];if(!e.length)return;let n=!1;const i=t.provider,r=t.prefix;e.forEach((e=>{const o=e.icons,s=o.pending.length;o.pending=o.pending.filter((e=>{if(e.prefix!==r)return!0;const s=e.name;if(t.icons[s])o.loaded.push({provider:i,prefix:r,name:s});else{if(!t.missing.has(s))return n=!0,!0;o.missing.push({provider:i,prefix:r,name:s})}return!1})),o.pending.length!==s&&(n||E([t],e.id),e.callback(o.loaded.slice(0),o.missing.slice(0),o.pending.slice(0),e.abort))}))})))}(t)})))}function K(t,e,n){function i(){const n=t.pendingIcons;e.forEach((e=>{n&&n.delete(e),t.icons[e]||t.missing.add(e)}))}if(n&&"object"==typeof n)try{if(!x(t,n).length)return void i()}catch(t){console.error(t)}i(),V(t)}function W(t,e){t instanceof Promise?t.then((t=>{e(t)})).catch((()=>{e(null)})):e(t)}function X(t,e){t.iconsToLoad?t.iconsToLoad=t.iconsToLoad.concat(e).sort():t.iconsToLoad=e,t.iconsQueueFlag||(t.iconsQueueFlag=!0,setTimeout((()=>{t.iconsQueueFlag=!1;const{provider:e,prefix:n}=t,i=t.iconsToLoad;if(delete t.iconsToLoad,!i||!i.length)return;const r=t.loadIcon;if(t.loadIcons&&(i.length>1||!r))return void W(t.loadIcons(i,n,e),(e=>{K(t,i,e)}));if(r)return void i.forEach((i=>{W(r(i,n,e),(e=>{K(t,[i],e?{prefix:n,icons:{[i]:e}}:null)}))}));const{valid:o,invalid:s}=function(t){const e=[],n=[];return t.forEach((t=>{(t.match(u)?e:n).push(t)})),{valid:e,invalid:n}}(i);if(s.length&&K(t,s,null),!o.length)return;const c=n.match(u)?S(e):null;if(!c)return void K(t,o,null);c.prepare(e,n,o).forEach((n=>{B(e,n,(e=>{K(t,n.icons,e)}))}))})))}const Y=(t,e)=>{const n=function(t,e=!0,n=!1){const i=[];return t.forEach((t=>{const r="string"==typeof t?l(t,e,n):t;r&&i.push(r)})),i}(t,!0,k()),i=function(t){const e={loaded:[],missing:[],pending:[]},n=Object.create(null);t.sort(((t,e)=>t.provider!==e.provider?t.provider.localeCompare(e.provider):t.prefix!==e.prefix?t.prefix.localeCompare(e.prefix):t.name.localeCompare(e.name)));let i={provider:"",prefix:"",name:""};return t.forEach((t=>{if(i.name===t.name&&i.prefix===t.prefix&&i.provider===t.provider)return;i=t;const r=t.provider,o=t.prefix,s=t.name,c=n[r]||(n[r]=Object.create(null)),a=c[o]||(c[o]=y(r,o));let u;u=s in a.icons?e.loaded:""===o||a.missing.has(s)?e.missing:e.pending;const l={provider:r,prefix:o,name:s};u.push(l)})),e}(n);if(!i.pending.length){let t=!0;return e&&setTimeout((()=>{t&&e(i.loaded,i.missing,i.pending,G)})),()=>{t=!1}}const r=Object.create(null),o=[];let s,c;return i.pending.forEach((t=>{const{provider:e,prefix:n}=t;if(n===c&&e===s)return;s=e,c=n,o.push(y(e,n));const i=r[e]||(r[e]=Object.create(null));i[n]||(i[n]=[])})),i.pending.forEach((t=>{const{provider:e,prefix:n,name:i}=t,o=y(e,n),s=o.pendingIcons||(o.pendingIcons=new Set);s.has(i)||(s.add(i),r[e][n].push(i))})),o.forEach((t=>{const e=r[t.provider][t.prefix];e.length&&X(t,e)})),e?function(t,e,n){const i=T++,r=E.bind(null,n,i);if(!e.pending.length)return r;const o={id:i,icons:e,callback:t,abort:r};return n.forEach((t=>{(t.loaderCallbacks||(t.loaderCallbacks=[])).push(o)})),r}(e,i,o):G},Z=t=>new Promise(((e,i)=>{const r="string"==typeof t?l(t,!0):t;r?Y([r||t],(o=>{if(o.length&&r){const t=A(r);if(t)return void e({...n,...t})}i(t)})):i(t)}));function tt(t){try{const e="string"==typeof t?JSON.parse(t):t;if("string"==typeof e.body)return{...e}}catch(t){}}let et=!1;try{et=0===navigator.vendor.indexOf("Apple")}catch(t){}const nt=/(-?[0-9.]*[0-9]+[0-9.]*)/g,it=/^-?[0-9.]*[0-9]+[0-9.]*$/g;function rt(t,e,n){if(1===e)return t;if(n=n||100,"number"==typeof t)return Math.ceil(t*e*n)/n;if("string"!=typeof t)return t;const i=t.split(nt);if(null===i||!i.length)return t;const r=[];let o=i.shift(),s=it.test(o);for(;;){if(s){const t=parseFloat(o);isNaN(t)?r.push(o):r.push(Math.ceil(t*e*n)/n)}else r.push(o);if(o=i.shift(),void 0===o)return r.join("");s=!s}}const ot=t=>"unset"===t||"undefined"===t||"none"===t;function st(t,e){const i={...n,...t},r={...o,...e},s={left:i.left,top:i.top,width:i.width,height:i.height};let c=i.body;[i,r].forEach((t=>{const e=[],n=t.hFlip,i=t.vFlip;let r,o=t.rotate;switch(n?i?o+=2:(e.push("translate("+(s.width+s.left).toString()+" "+(0-s.top).toString()+")"),e.push("scale(-1 1)"),s.top=s.left=0):i&&(e.push("translate("+(0-s.left).toString()+" "+(s.height+s.top).toString()+")"),e.push("scale(1 -1)"),s.top=s.left=0),o<0&&(o-=4*Math.floor(o/4)),o%=4,o){case 1:r=s.height/2+s.top,e.unshift("rotate(90 "+r.toString()+" "+r.toString()+")");break;case 2:e.unshift("rotate(180 "+(s.width/2+s.left).toString()+" "+(s.height/2+s.top).toString()+")");break;case 3:r=s.width/2+s.left,e.unshift("rotate(-90 "+r.toString()+" "+r.toString()+")")}o%2==1&&(s.left!==s.top&&(r=s.left,s.left=s.top,s.top=r),s.width!==s.height&&(r=s.width,s.width=s.height,s.height=r)),e.length&&(c=function(t,e,n){const i=function(t,e="defs"){let n="";const i=t.indexOf("<"+e);for(;i>=0;){const r=t.indexOf(">",i),o=t.indexOf("",o);if(-1===s)break;n+=t.slice(r+1,o).trim(),t=t.slice(0,i).trim()+t.slice(s+1)}return{defs:n,content:t}}(t);return r=i.defs,o=e+i.content+n,r?""+r+""+o:o;var r,o}(c,'',""))}));const a=r.width,u=r.height,l=s.width,f=s.height;let d,h;null===a?(h=null===u?"1em":"auto"===u?f:u,d=rt(h,l/f)):(d="auto"===a?l:a,h=null===u?rt(d,f/l):"auto"===u?f:u);const p={},g=(t,e)=>{ot(e)||(p[t]=e.toString())};g("width",d),g("height",h);const b=[s.left,s.top,l,f];return p.viewBox=b.join(" "),{attributes:p,viewBox:b,body:c}}function ct(t,e){let n=-1===t.indexOf("xlink:")?"":' xmlns:xlink="http://www.w3.org/1999/xlink"';for(const t in e)n+=" "+t+'="'+e[t]+'"';return'"+t+""}function at(t){return'url("'+function(t){return"data:image/svg+xml,"+function(t){return t.replace(/"/g,"'").replace(/%/g,"%25").replace(/#/g,"%23").replace(//g,"%3E").replace(/\s+/g," ")}(t)}(t)+'")'}let ut=(()=>{let t;try{if(t=fetch,"function"==typeof t)return t}catch(t){}})();function lt(t){ut=t}function ft(){return ut}const dt={prepare:(t,e,n)=>{const i=[],r=function(t,e){const n=D(t);if(!n)return 0;let i;if(n.maxURL){let t=0;n.resources.forEach((e=>{const n=e;t=Math.max(t,n.length)}));const r=e+".json?icons=";i=n.maxURL-t-n.path.length-r.length}else i=0;return i}(t,e),o="icons";let s={type:o,provider:t,prefix:e,icons:[]},c=0;return n.forEach(((n,a)=>{c+=n.length+1,c>=r&&a>0&&(i.push(s),s={type:o,provider:t,prefix:e,icons:[]},c=n.length),s.icons.push(n)})),i.push(s),i},send:(t,e,n)=>{if(!ut)return void n("abort",424);let i=function(t){if("string"==typeof t){const e=D(t);if(e)return e.path}return"/"}(e.provider);switch(e.type){case"icons":{const t=e.prefix,n=e.icons.join(",");i+=t+".json?"+new URLSearchParams({icons:n}).toString();break}case"custom":{const t=e.uri;i+="/"===t.slice(0,1)?t.slice(1):t;break}default:return void n("abort",400)}let r=503;ut(t+i).then((t=>{const e=t.status;if(200===e)return r=501,t.json();setTimeout((()=>{n(function(t){return 404===t}(e)?"abort":"next",e)}))})).then((t=>{"object"==typeof t&&null!==t?setTimeout((()=>{n("success",t)})):setTimeout((()=>{404===t?n("abort",t):n("next",r)}))})).catch((()=>{n("next",r)}))}};function ht(t,e,n){y(n||"",e).loadIcons=t}function pt(t,e,n){y(n||"",e).loadIcon=t}const gt="data-style";let bt="";function vt(t){bt=t}function mt(t,e){let n=Array.from(t.childNodes).find((t=>t.hasAttribute&&t.hasAttribute(gt)));n||(n=document.createElement("style"),n.setAttribute(gt,gt),t.appendChild(n)),n.textContent=":host{display:inline-block;vertical-align:"+(e?"-0.125em":"0")+"}span,svg{display:block;margin:auto}"+bt}const yt={"background-color":"currentColor"},xt={"background-color":"transparent"},_t={image:"var(--svg)",repeat:"no-repeat",size:"100% 100%"},wt={"-webkit-mask":yt,mask:yt,background:xt};for(const t in wt){const e=wt[t];for(const n in _t)e[t+"-"+n]=_t[n]}function kt(t){return t?t+(t.match(/^[-0-9.]+$/)?"px":""):"inherit"}let At;function jt(t){return void 0===At&&function(){try{At=window.trustedTypes.createPolicy("iconify",{createHTML:t=>t})}catch(t){At=null}}(),At?At.createHTML(t):t}function Ot(t){return Array.from(t.childNodes).find((t=>{const e=t.tagName&&t.tagName.toUpperCase();return"SPAN"===e||"SVG"===e}))}function Ct(t,e){const i=e.icon.data,r=e.customisations,o=st(i,r);r.preserveAspectRatio&&(o.attributes.preserveAspectRatio=r.preserveAspectRatio);const s=e.renderedMode;let c;if("svg"===s)c=function(t){const e=document.createElement("span"),n=t.attributes;let i="";n.width||(i="width: inherit;"),n.height||(i+="height: inherit;"),i&&(n.style=i);const r=ct(t.body,n);return e.innerHTML=jt(r),e.firstChild}(o);else c=function(t,e,n){const i=document.createElement("span");let r=t.body;-1!==r.indexOf("{this._check()})))}_check(){if(!this._checkQueued)return;this._checkQueued=!1;const t=this._state,e=this.getAttribute("icon");if(e!==t.icon.value)return void this._iconChanged(e);if(!t.rendered||!this._visible)return;const n=this.getAttribute("mode"),i=a(this);t.attrMode===n&&!function(t,e){for(const n in c)if(t[n]!==e[n])return!0;return!1}(t.customisations,i)&&Ot(this._shadowRoot)||this._renderIcon(t.icon,i,n)}_iconChanged(t){const e=function(t,e){if("object"==typeof t)return{data:tt(t),value:t};if("string"!=typeof t)return{value:t};if(t.includes("{")){const e=tt(t);if(e)return{data:e,value:t}}const n=l(t,!0,!0);if(!n)return{value:t};const i=A(n);if(void 0!==i||!n.prefix)return{value:t,name:n,data:i};const r=Y([n],(()=>e(t,n,A(n))));return{value:t,name:n,loading:r}}(t,((t,e,n)=>{const i=this._state;if(i.rendered||this.getAttribute("icon")!==t)return;const r={value:t,name:e,data:n};r.data?this._gotIconData(r):i.icon=r}));e.data?this._gotIconData(e):this._state=It(e,this._state.inline,this._state)}_forceRender(){if(this._visible)this._queueCheck();else{const t=Ot(this._shadowRoot);t&&this._shadowRoot.removeChild(t)}}_gotIconData(t){this._checkQueued=!1,this._renderIcon(t,a(this),this.getAttribute("mode"))}_renderIcon(t,e,n){const i=function(t,e){switch(e){case"svg":case"bg":case"mask":return e}return"style"===e||!et&&-1!==t.indexOf("{const e=t.some((t=>t.isIntersecting));e!==this._visible&&(this._visible=e,this._forceRender())})),this._observer.observe(this)}catch(t){if(this._observer){try{this._observer.disconnect()}catch(t){}this._observer=null}}}stopObserver(){this._observer&&(this._observer.disconnect(),this._observer=null,this._visible=!0,this._connected&&this._forceRender())}};r.forEach((t=>{t in o.prototype||Object.defineProperty(o.prototype,t,{get:function(){return this.getAttribute(t)},set:function(e){null!==e?this.setAttribute(t,e):this.removeAttribute(t)}})}));const s=function(){let t;R("",dt),k(!0);try{t=window}catch(t){}if(t){if(void 0!==t.IconifyPreload){const e=t.IconifyPreload,n="Invalid IconifyPreload syntax.";"object"==typeof e&&null!==e&&(e instanceof Array?e:[e]).forEach((t=>{try{("object"!=typeof t||null===t||t instanceof Array||"object"!=typeof t.icons||"string"!=typeof t.prefix||!O(t))&&console.error(n)}catch(t){console.error(n)}}))}if(void 0!==t.IconifyProviders){const e=t.IconifyProviders;if("object"==typeof e&&null!==e)for(const t in e){const n="IconifyProviders["+t+"] is invalid.";try{const i=e[t];if("object"!=typeof i||!i||void 0===i.resources)continue;U(t,i)||console.error(n)}catch(t){console.error(n)}}}}return{enableCache:t=>{},disableCache:t=>{},iconLoaded:C,iconExists:C,getIcon:I,listIcons:_,addIcon:j,addCollection:O,calculateSize:rt,buildIcon:st,iconToHTML:ct,svgToURL:at,loadIcons:Y,loadIcon:Z,addAPIProvider:U,setCustomIconLoader:pt,setCustomIconsLoader:ht,appendCustomStyle:vt,_api:{getAPIConfig:D,setAPIModule:R,sendAPIQuery:B,setFetch:lt,getFetch:ft,listAPIProviders:H}}}();for(const t in s)o[t]=o.prototype[t]=s[t];e.define(t,o)}()}(); diff --git a/web_iconify/static/src/css/web_iconify.css b/web_iconify/static/src/css/web_iconify.css new file mode 100644 index 00000000000..265f70dfbae --- /dev/null +++ b/web_iconify/static/src/css/web_iconify.css @@ -0,0 +1,5 @@ +iconify-icon { + display: inline-block; + width: 1em; + height: 1em; +} diff --git a/web_iconify_proxy/README.rst b/web_iconify_proxy/README.rst new file mode 100644 index 00000000000..33658c42961 --- /dev/null +++ b/web_iconify_proxy/README.rst @@ -0,0 +1,120 @@ +================= +Web Iconify Proxy +================= + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:540c039653c052dd03103f8763a28c639f23b6e3a74191b78a2dd984352b5127 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-LGPL--3-blue.png + :target: http://www.gnu.org/licenses/lgpl-3.0-standalone.html + :alt: License: LGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fweb-lightgray.png?logo=github + :target: https://github.com/OCA/web/tree/18.0/web_iconify_proxy + :alt: OCA/web +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/web-18-0/web-18-0-web_iconify_proxy + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/web&target_branch=18.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module acts as a proxy for the Iconify API, allowing Odoo to fetch +icons through the Odoo server rather than directly from the Iconify API. +This improves performance by caching the icons locally using Odoo's +ir.attachment model. + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +This module works in conjunction with web_iconify. Once installed, icons +will be served through the proxy and cached locally. + +SVG Icon Parameters +------------------- + +You can customize SVG icons by adding query parameters to the URL. The +format is: + +``/web_iconify_proxy//.svg?param1=value1¶m2=value2...`` + +Available parameters: + +- **color**: Icon color (e.g., ``color=red``, ``color=%23ff0000``). +- **width**: Icon width (e.g., ``width=50``, ``width=50px``). +- **height**: Icon height (e.g., ``height=50``, ``height=50px``). If + only one dimension is specified, the other will be calculated + automatically to maintain aspect ratio. +- **flip**: Flip the icon. Possible values: ``horizontal``, + ``vertical``, or both (e.g., ``flip=horizontal``, ``flip=vertical``, + ``flip=horizontal,vertical``). +- **rotate**: Rotate the icon by 90, 180, or 270 degrees (e.g., + ``rotate=90``, ``rotate=180``). +- **box**: Set to ``true`` to add an empty rectangle to the SVG that + matches the viewBox (e.g., ``box=true``). + +Example: + +``/web_iconify_proxy/mdi/home.svg?color=blue&width=64&flip=horizontal`` + +Changelog +========= + +18.0.1.0.0 (2025-02-23) +----------------------- + +- Initial version. + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* jaco.tech + +Contributors +------------ + +- Jaco Waes + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +This module is part of the `OCA/web `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/web_iconify_proxy/__init__.py b/web_iconify_proxy/__init__.py new file mode 100644 index 00000000000..e046e49fbe2 --- /dev/null +++ b/web_iconify_proxy/__init__.py @@ -0,0 +1 @@ +from . import controllers diff --git a/web_iconify_proxy/__manifest__.py b/web_iconify_proxy/__manifest__.py new file mode 100644 index 00000000000..4f5fe5ad5a0 --- /dev/null +++ b/web_iconify_proxy/__manifest__.py @@ -0,0 +1,24 @@ +# Copyright 2025 jaco.tech +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +{ + "name": "Web Iconify Proxy", + "summary": "Proxies requests to the Iconify API, providing SVG icons, CSS," + " and JSON data. It also implements caching using Odoo's " + "ir.attachment model.", + "version": "18.0.1.0.0", + "category": "Website", + "website": "https://github.com/OCA/web", + "author": "jaco.tech, Odoo Community Association (OCA)", + "license": "LGPL-3", + "depends": ["web"], + "data": [], + "assets": { + "web.assets_backend": [ + "web_iconify_proxy/static/src/js/iconify_api_provider.js", + ], + }, + "installable": True, + "auto_install": False, + "tests": ["tests/test_main.py"], +} diff --git a/web_iconify_proxy/controllers/__init__.py b/web_iconify_proxy/controllers/__init__.py new file mode 100644 index 00000000000..12a7e529b67 --- /dev/null +++ b/web_iconify_proxy/controllers/__init__.py @@ -0,0 +1 @@ +from . import main diff --git a/web_iconify_proxy/controllers/main.py b/web_iconify_proxy/controllers/main.py new file mode 100644 index 00000000000..dd7119d8520 --- /dev/null +++ b/web_iconify_proxy/controllers/main.py @@ -0,0 +1,261 @@ +import ast +import base64 +import datetime +import logging +import re + +import requests + +from odoo import http +from odoo.http import request + +_logger = logging.getLogger(__name__) + + +class IconifyProxyController(http.Controller): + """Controller for proxying Iconify requests.""" + + def _fetch_iconify_data( + self, + upstream_url, + content_type, + prefix, + icons=None, + icon=None, + normalized_params_string="", + ): + """Fetches data from the Iconify API or the local cache. + + Args: + upstream_url (str): The URL of the Iconify API endpoint. + content_type (str): The expected content type of the response. + prefix (str): The icon prefix. + icons (str, optional): Comma-separated list of icons (for CSS and JSON). + icon (str, optional): The icon name (for SVG). + normalized_params_string (str, optional): Normalized parameters string. + + Returns: + Response: The HTTP response. + """ + + # Validate prefix + prefix = prefix.lower() + + # Validate prefix + if not re.match(r"^[a-z0-9-]+$", prefix): + raise request.not_found() + + # Validate icon (if provided) + if icon: + icon = icon.lower() + if not re.match(r"^[a-z0-9:-]+$", icon): + raise request.not_found() + + # Validate icons (if provided) + if icons: + icon_list = [i.lower() for i in icons.split(",")] + for single_icon in icon_list: + if not re.match(r"^[a-z0-9:-]+$", single_icon): + raise request.not_found() + icons = ",".join(icon_list) # Reconstruct to prevent injection + + Attachment = request.env["ir.attachment"].sudo() + if content_type == "image/svg+xml": + name = f"{prefix}-{icon}-{normalized_params_string.lower()}" + res_model = "iconify.svg" + elif content_type == "text/css": + name = f"{prefix}-{icons}-{normalized_params_string.lower()}" + res_model = "iconify.css" + elif content_type == "application/json": + name = f"{prefix}-{icons}-{normalized_params_string.lower()}" + res_model = "iconify.json" + else: + raise request.not_found() + + attachment = Attachment.search( + [("res_model", "=", res_model), ("name", "=", name)], limit=1 + ) + + if attachment: + _logger.info(f"Serving from cache: {name}") + data = base64.b64decode(attachment.datas) + headers = [ + ("Content-Type", content_type), + ("Cache-Control", "public, max-age=31536000"), + ("X-Cached-At", str(attachment.create_date)), + ] + return request.make_response(data, headers) + + _logger.info(f"Fetching from API: {upstream_url}") + try: + response = requests.get(upstream_url, timeout=5) + response.raise_for_status() # Raise HTTPError for bad responses + except requests.exceptions.RequestException as e: + _logger.error(f"Request to Iconify API failed: {e}") + raise request.not_found() from e + + data = response.content + attachment = Attachment.create( + { + "name": name, + "datas": base64.b64encode(data).decode("utf-8"), + "res_model": res_model, + "res_id": 0, + "type": "binary", + } + ) + + headers = [ + ("Content-Type", content_type), + ("Cache-Control", "public, max-age=31536000"), # Cache for one year + ] + return request.make_response(data, headers) + + def _normalize_params_svg(self, params): + """Normalizes parameters specifically for SVG requests.""" + allowed_params = ["color", "width", "height", "flip", "rotate", "box"] + normalized = [] + for key in sorted(params.keys()): + if key in allowed_params: + value = params[key] + key = key.lower() + # Basic type validation + if key in ("width", "height", "rotate") and not ( + isinstance(value, str) or isinstance(value, int) + ): + continue # Skip invalid values + if key == "box": + try: + value = ast.literal_eval(str(value).lower()) + if not isinstance(value, bool): + continue + except (ValueError, SyntaxError): + continue + normalized.append(f"{key}={value}") + return ";".join(normalized) + + @http.route( + "/web_iconify_proxy//.svg", + type="http", + auth="public", + methods=["GET"], + csrf=False, + ) + def get_svg(self, prefix, icon, **params): + """Gets an SVG icon from the Iconify API. + + Args: + prefix (str): The icon prefix. + icon (str): The icon name. + + Returns: + Response: The HTTP response containing the SVG data. + """ + normalized_params = self._normalize_params_svg(params) + if normalized_params: + query_string = normalized_params.replace(";", "&") + upstream_url = ( + f"https://api.iconify.design/{prefix}/{icon}.svg?{query_string}" + ) + else: + upstream_url = f"https://api.iconify.design/{prefix}/{icon}.svg" + + return self._fetch_iconify_data( + upstream_url, + "image/svg+xml", + prefix, + icon=icon, + normalized_params_string=normalized_params, + ) + + @http.route( + "/web_iconify_proxy/.css", + type="http", + auth="public", + methods=["GET"], + csrf=False, + ) + def get_css(self, prefix, **params): + """Gets CSS for a set of icons from the Iconify API. + + Args: + prefix (str): The icon prefix. + params (dict): Query parameters, including 'icons'. + + Returns: + Response: The HTTP response containing the CSS data. + """ + icons = params.get("icons") + if not icons: + raise request.not_found() + upstream_url = f"https://api.iconify.design/{prefix}.css?icons={icons}" + return self._fetch_iconify_data(upstream_url, "text/css", prefix, icons=icons) + + @http.route( + "/web_iconify_proxy/.json", + type="http", + auth="public", + methods=["GET"], + csrf=False, + ) + def get_json(self, prefix, **params): + """Gets JSON data for a set of icons from the Iconify API. + + Args: + prefix (str): The icon prefix. + params (dict): Query parameters, including 'icons'. + + Returns: + Response: The HTTP response containing the JSON data. + """ + icons = params.get("icons") + if not icons: + raise request.not_found() + upstream_url = f"https://api.iconify.design/{prefix}.json?icons={icons}" + return self._fetch_iconify_data( + upstream_url, "application/json", prefix, icons=icons + ) + + @http.route( + "/web_iconify_proxy/last-modified", + type="http", + auth="public", + methods=["GET"], + csrf=False, + ) + def get_last_modified(self, **params): + """Gets the last modification timestamp for the cached data. + + Args: + params (dict): Query parameters, including 'prefixes'. + + Returns: + Response: The HTTP response containing the timestamp. + """ + prefixes = params.get("prefixes") + if not prefixes: + raise request.not_found() + + prefixes_list = prefixes.split(",") + + Attachment = request.env["ir.attachment"].sudo() + + # Search for attachments related to iconify + attachments = Attachment.search( + [ + ("res_model", "in", ["iconify.svg", "iconify.css", "iconify.json"]), + # Check if name contains any of the prefixes + ("name", "like", "|".join(prefixes_list)), + ] + ) + + if not attachments: + raise request.not_found() + + # Find the latest create_date + latest_timestamp = max( + attachments.mapped("create_date"), default=datetime.datetime.min + ) + + headers = [("Content-Type", "application/json"), ("Cache-Control", "no-cache")] + return request.make_response(str(latest_timestamp.timestamp()), headers) diff --git a/web_iconify_proxy/pyproject.toml b/web_iconify_proxy/pyproject.toml new file mode 100644 index 00000000000..4231d0cccb3 --- /dev/null +++ b/web_iconify_proxy/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/web_iconify_proxy/readme/CONTRIBUTORS.md b/web_iconify_proxy/readme/CONTRIBUTORS.md new file mode 100644 index 00000000000..2541b203029 --- /dev/null +++ b/web_iconify_proxy/readme/CONTRIBUTORS.md @@ -0,0 +1 @@ +- Jaco Waes \ diff --git a/web_iconify_proxy/readme/DESCRIPTION.md b/web_iconify_proxy/readme/DESCRIPTION.md new file mode 100644 index 00000000000..e9134e370c9 --- /dev/null +++ b/web_iconify_proxy/readme/DESCRIPTION.md @@ -0,0 +1,4 @@ +This module acts as a proxy for the Iconify API, allowing Odoo to fetch +icons through the Odoo server rather than directly from the Iconify API. +This improves performance by caching the icons locally using Odoo's +ir.attachment model. diff --git a/web_iconify_proxy/readme/HISTORY.md b/web_iconify_proxy/readme/HISTORY.md new file mode 100644 index 00000000000..90f6389743d --- /dev/null +++ b/web_iconify_proxy/readme/HISTORY.md @@ -0,0 +1,3 @@ +## 18.0.1.0.0 (2025-02-23) + +- Initial version. diff --git a/web_iconify_proxy/readme/USAGE.md b/web_iconify_proxy/readme/USAGE.md new file mode 100644 index 00000000000..ff4dbaf9d05 --- /dev/null +++ b/web_iconify_proxy/readme/USAGE.md @@ -0,0 +1,21 @@ +This module works in conjunction with web_iconify. Once installed, icons +will be served through the proxy and cached locally. + +## SVG Icon Parameters + +You can customize SVG icons by adding query parameters to the URL. The format is: + +`/web_iconify_proxy//.svg?param1=value1¶m2=value2...` + +Available parameters: + +* **color**: Icon color (e.g., `color=red`, `color=%23ff0000`). +* **width**: Icon width (e.g., `width=50`, `width=50px`). +* **height**: Icon height (e.g., `height=50`, `height=50px`). If only one dimension is specified, the other will be calculated automatically to maintain aspect ratio. +* **flip**: Flip the icon. Possible values: `horizontal`, `vertical`, or both (e.g., `flip=horizontal`, `flip=vertical`, `flip=horizontal,vertical`). +* **rotate**: Rotate the icon by 90, 180, or 270 degrees (e.g., `rotate=90`, `rotate=180`). +* **box**: Set to `true` to add an empty rectangle to the SVG that matches the viewBox (e.g., `box=true`). + +Example: + +`/web_iconify_proxy/mdi/home.svg?color=blue&width=64&flip=horizontal` diff --git a/web_iconify_proxy/static/description/index.html b/web_iconify_proxy/static/description/index.html new file mode 100644 index 00000000000..020a34cc47f --- /dev/null +++ b/web_iconify_proxy/static/description/index.html @@ -0,0 +1,471 @@ + + + + + +Web Iconify Proxy + + + +
+

Web Iconify Proxy

+ + +

Beta License: LGPL-3 OCA/web Translate me on Weblate Try me on Runboat

+

This module acts as a proxy for the Iconify API, allowing Odoo to fetch +icons through the Odoo server rather than directly from the Iconify API. +This improves performance by caching the icons locally using Odoo’s +ir.attachment model.

+

Table of contents

+ +
+

Usage

+

This module works in conjunction with web_iconify. Once installed, icons +will be served through the proxy and cached locally.

+
+

SVG Icon Parameters

+

You can customize SVG icons by adding query parameters to the URL. The +format is:

+

/web_iconify_proxy/<prefix>/<icon>.svg?param1=value1&param2=value2...

+

Available parameters:

+
    +
  • color: Icon color (e.g., color=red, color=%23ff0000).
  • +
  • width: Icon width (e.g., width=50, width=50px).
  • +
  • height: Icon height (e.g., height=50, height=50px). If +only one dimension is specified, the other will be calculated +automatically to maintain aspect ratio.
  • +
  • flip: Flip the icon. Possible values: horizontal, +vertical, or both (e.g., flip=horizontal, flip=vertical, +flip=horizontal,vertical).
  • +
  • rotate: Rotate the icon by 90, 180, or 270 degrees (e.g., +rotate=90, rotate=180).
  • +
  • box: Set to true to add an empty rectangle to the SVG that +matches the viewBox (e.g., box=true).
  • +
+

Example:

+

/web_iconify_proxy/mdi/home.svg?color=blue&width=64&flip=horizontal

+
+
+
+

Changelog

+
+

18.0.1.0.0 (2025-02-23)

+
    +
  • Initial version.
  • +
+
+
+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • jaco.tech
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

This module is part of the OCA/web project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/web_iconify_proxy/static/src/js/iconify_api_provider.js b/web_iconify_proxy/static/src/js/iconify_api_provider.js new file mode 100644 index 00000000000..34e1c1993f5 --- /dev/null +++ b/web_iconify_proxy/static/src/js/iconify_api_provider.js @@ -0,0 +1,8 @@ +/** @odoo-module ignore **/ + +// eslint-disable-next-line no-undef +const IconifyIcon = window.customElements.get("iconify-icon"); + +IconifyIcon.addAPIProvider("", { + resources: ["/web_iconify_proxy"], +}); diff --git a/web_iconify_proxy/tests/__init__.py b/web_iconify_proxy/tests/__init__.py new file mode 100644 index 00000000000..6c9812dc2f7 --- /dev/null +++ b/web_iconify_proxy/tests/__init__.py @@ -0,0 +1 @@ +from . import test_main diff --git a/web_iconify_proxy/tests/test_main.py b/web_iconify_proxy/tests/test_main.py new file mode 100644 index 00000000000..27e0218e753 --- /dev/null +++ b/web_iconify_proxy/tests/test_main.py @@ -0,0 +1,272 @@ +import base64 +from unittest.mock import patch + +import requests + +from odoo.tests.common import HttpCase, tagged + + +@tagged("post_install", "-at_install") +class TestIconifyProxyController(HttpCase): + @patch("odoo.addons.web_iconify_proxy.controllers.main.requests.get") + def test_get_svg_success(self, mock_get): + mock_response = requests.Response() + mock_response.status_code = 200 + mock_response._content = b"dummy content" + mock_response.headers["Content-Type"] = "image/svg+xml" + mock_get.return_value = mock_response + + response = self.url_open("/web_iconify_proxy/mdi/home.svg") + self.assertEqual(response.status_code, 200) + self.assertEqual(response.headers["Content-Type"], "image/svg+xml") + # Add basic check for SVG content (can be improved) + self.assertTrue(b"dummy content" + mock_response.headers["Content-Type"] = "image/svg+xml" + mock_get.return_value = mock_response + + # First request, should fetch from API + response1 = self.url_open("/web_iconify_proxy/mdi/home.svg") + self.assertEqual(response1.status_code, 200) + self.assertFalse("X-Cached-At" in response1.headers) # Check that is NOT cached + + # Second request, should be served from cache + response2 = self.url_open("/web_iconify_proxy/mdi/home.svg") + self.assertEqual(response2.status_code, 200) + self.assertTrue("X-Cached-At" in response2.headers) # Check that IS cached + + # Check that content is the same + self.assertEqual(response1.content, response2.content) + + @patch("odoo.addons.web_iconify_proxy.controllers.main.requests.get") + def test_caching_with_params(self, mock_get): + """Test caching with different parameters.""" + mock_response = requests.Response() + mock_response.status_code = 200 + mock_response._content = b"dummy content" + mock_response.headers["Content-Type"] = "image/svg+xml" + mock_get.return_value = mock_response + + # First request with specific parameters + response1 = self.url_open( + "/web_iconify_proxy/mdi/home.svg?color=red&width=50&flip=horizontal" + ) + self.assertEqual(response1.status_code, 200) + self.assertFalse("X-Cached-At" in response1.headers) + + # Second request with the same parameters, should be cached + response2 = self.url_open( + "/web_iconify_proxy/mdi/home.svg?color=red&width=50&flip=horizontal" + ) + self.assertEqual(response2.status_code, 200) + self.assertTrue("X-Cached-At" in response2.headers) + + # Third request with different parameters, should not be cached + response3 = self.url_open( + "/web_iconify_proxy/mdi/home.svg?color=blue&width=100" + ) + self.assertEqual(response3.status_code, 200) + self.assertFalse("X-Cached-At" in response3.headers) + + @patch("odoo.addons.web_iconify_proxy.controllers.main.requests.get") + def test_caching_parameter_order(self, mock_get): + """Test that parameter order doesn't affect caching.""" + mock_response = requests.Response() + mock_response.status_code = 200 + mock_response._content = b"dummy content" + mock_response.headers["Content-Type"] = "image/svg+xml" + mock_get.return_value = mock_response + + # First request with specific parameter order + response1 = self.url_open( + "/web_iconify_proxy/mdi/home.svg?color=red&width=50&flip=horizontal" + ) + self.assertEqual(response1.status_code, 200) + self.assertFalse("X-Cached-At" in response1.headers) + + # Second request with different parameter order, should be cached + response2 = self.url_open( + "/web_iconify_proxy/mdi/home.svg?flip=horizontal&width=50&color=red" + ) + self.assertEqual(response2.status_code, 200) + self.assertTrue("X-Cached-At" in response2.headers) + + # Check that content is the same + self.assertEqual(response1.content, response2.content) + + @patch("odoo.addons.web_iconify_proxy.controllers.main.requests.get") + def test_caching_boolean_values(self, mock_get): + """Test that boolean values are case-insensitive for caching.""" + mock_response = requests.Response() + mock_response.status_code = 200 + mock_response._content = b"dummy content" + mock_response.headers["Content-Type"] = "image/svg+xml" + mock_get.return_value = mock_response + + # First request with "true" + response1 = self.url_open("/web_iconify_proxy/mdi/home.svg?box=true") + self.assertEqual(response1.status_code, 200) + self.assertFalse("X-Cached-At" in response1.headers) + + # Second request with "True", should be cached + response2 = self.url_open("/web_iconify_proxy/mdi/home.svg?box=True") + self.assertEqual(response2.status_code, 200) + self.assertTrue("X-Cached-At" in response2.headers) + + # Third request with "TRUE", should be cached + response3 = self.url_open("/web_iconify_proxy/mdi/home.svg?box=TRUE") + self.assertEqual(response3.status_code, 200) + self.assertTrue("X-Cached-At" in response3.headers) + + # Check that content is the same for all + self.assertEqual(response1.content, response2.content) + self.assertEqual(response1.content, response3.content) + + @patch("odoo.addons.web_iconify_proxy.controllers.main.requests.get") + def test_get_svg_with_parameters(self, mock_get): + """Test the get_svg route with various valid parameters.""" + mock_response = requests.Response() + mock_response.status_code = 200 + mock_response._content = b"dummy content" + mock_response.headers["Content-Type"] = "image/svg+xml" + mock_get.return_value = mock_response + + test_cases = [ + "/web_iconify_proxy/mdi/home.svg?color=red", + "/web_iconify_proxy/mdi/home.svg?width=50", + "/web_iconify_proxy/mdi/home.svg?height=50", + "/web_iconify_proxy/mdi/home.svg?flip=horizontal", + "/web_iconify_proxy/mdi/home.svg?flip=vertical", + "/web_iconify_proxy/mdi/home.svg?flip=horizontal,vertical", + "/web_iconify_proxy/mdi/home.svg?rotate=90", + "/web_iconify_proxy/mdi/home.svg?rotate=180", + "/web_iconify_proxy/mdi/home.svg?rotate=270", + "/web_iconify_proxy/mdi/home.svg?box=true", + "/web_iconify_proxy/mdi/home.svg?color=blue&width=64&flip=horizontal&rotate=180", + ] + for url in test_cases: + with self.subTest(url=url): + response = self.url_open(url) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.headers["Content-Type"], "image/svg+xml") + + @patch("odoo.addons.web_iconify_proxy.controllers.main.requests.get") + def test_get_svg_with_invalid_parameters(self, mock_get): + """Test the get_svg route with invalid parameters.""" + mock_response = requests.Response() + mock_response.status_code = 200 + mock_response._content = b"dummy content" + mock_response.headers["Content-Type"] = "image/svg+xml" + mock_get.return_value = mock_response + + test_cases = [ + "/web_iconify_proxy/mdi/home.svg?width=invalid", # Invalid width + "/web_iconify_proxy/mdi/home.svg?height=invalid", # Invalid height + "/web_iconify_proxy/mdi/home.svg?rotate=invalid", # Invalid rotate + "/web_iconify_proxy/mdi/home.svg?box=invalid", # Invalid box + "/web_iconify_proxy/mdi/home.svg?unknown=param", # Unknown parameter + ] + for url in test_cases: + with self.subTest(url=url): + response = self.url_open(url) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.headers["Content-Type"], "image/svg+xml") + + @patch("odoo.addons.web_iconify_proxy.controllers.main.requests.get") + def test_api_error(self, mock_get): + # Mock requests.get to simulate an API error + mock_get.side_effect = requests.exceptions.RequestException("Simulated Error") + with self.assertLogs(level="ERROR") as log: + response = self.url_open("/web_iconify_proxy/mdi/home.svg") + self.assertEqual(response.status_code, 404) # Expect 404 Not Found + self.assertIn("Simulated Error", log.output[0]) diff --git a/web_no_bubble/__manifest__.py b/web_no_bubble/__manifest__.py index e8ecefdc579..b0e12e92cd6 100644 --- a/web_no_bubble/__manifest__.py +++ b/web_no_bubble/__manifest__.py @@ -4,7 +4,7 @@ { "name": "Web No Bubble", "version": "18.0.1.0.0", - "author": "Savoir-faire Linux, " "Odoo Community Association (OCA)", + "author": "Savoir-faire Linux, Odoo Community Association (OCA)", "website": "https://github.com/OCA/web", "license": "AGPL-3", "category": "Web", diff --git a/web_notify/README.rst b/web_notify/README.rst index 27391499378..04a00e5faa8 100644 --- a/web_notify/README.rst +++ b/web_notify/README.rst @@ -34,11 +34,11 @@ This technical module allows you to send instant notification messages from the server to the user in live. Two kinds of notification are supported. -- Success: Displayed in a success theme color flying popup div -- Danger: Displayed in a danger theme color flying popup div -- Warning: Displayed in a warning theme color flying popup div -- Information: Displayed in a info theme color flying popup div -- Default: Displayed in a default theme color flying popup div +- Success: Displayed in a success theme color flying popup div +- Danger: Displayed in a danger theme color flying popup div +- Warning: Displayed in a warning theme color flying popup div +- Information: Displayed in a info theme color flying popup div +- Default: Displayed in a default theme color flying popup div **Table of contents** @@ -87,8 +87,8 @@ or The notifications can bring interactivity with some buttons. -- One allowing to refresh the active view -- Another allowing to send a window / client action +- One allowing to refresh the active view +- Another allowing to send a window / client action The reload button is activated when sending the notification with: @@ -142,17 +142,17 @@ Authors Contributors ------------ -- Laurent Mignon -- Serpent Consulting Services Pvt. Ltd. -- Aitor Bouzas -- Shepilov Vladislav -- Kevin Khao -- `Tecnativa `__: +- Laurent Mignon +- Serpent Consulting Services Pvt. Ltd. +- Aitor Bouzas +- Shepilov Vladislav +- Kevin Khao +- `Tecnativa `__: - - David Vidal + - David Vidal -- Nikul Chaudhary -- Tris Doan +- Nikul Chaudhary +- Tris Doan Other credits ------------- diff --git a/web_notify/__manifest__.py b/web_notify/__manifest__.py index 47c7084383c..f8c2aebdc38 100644 --- a/web_notify/__manifest__.py +++ b/web_notify/__manifest__.py @@ -8,7 +8,7 @@ Send notification messages to user""", "version": "18.0.1.0.1", "license": "AGPL-3", - "author": "ACSONE SA/NV," "AdaptiveCity," "Odoo Community Association (OCA)", + "author": "ACSONE SA/NV,AdaptiveCity,Odoo Community Association (OCA)", "development_status": "Production/Stable", "website": "https://github.com/OCA/web", "depends": ["web", "bus", "base", "mail"],