mirror of
https://github.com/ynput/ayon-core.git
synced 2026-01-01 16:34:53 +01:00
Merge branch 'develop' into bugfix/1139-version_up_current_workfile-doesnt-return-the-first-workfile-name-if-there-are-no-workfiles
This commit is contained in:
commit
0cf4e3576d
17 changed files with 1029 additions and 343 deletions
|
|
@ -8,7 +8,6 @@ from pathlib import Path
|
|||
import warnings
|
||||
|
||||
import click
|
||||
import acre
|
||||
|
||||
from ayon_core import AYON_CORE_ROOT
|
||||
from ayon_core.addon import AddonsManager
|
||||
|
|
@ -18,6 +17,11 @@ from ayon_core.lib import (
|
|||
is_running_from_build,
|
||||
Logger,
|
||||
)
|
||||
from ayon_core.lib.env_tools import (
|
||||
parse_env_variables_structure,
|
||||
compute_env_variables_structure,
|
||||
merge_env_variables,
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
|
@ -235,19 +239,15 @@ def version(build):
|
|||
|
||||
def _set_global_environments() -> None:
|
||||
"""Set global AYON environments."""
|
||||
general_env = get_general_environments()
|
||||
# First resolve general environment
|
||||
general_env = parse_env_variables_structure(get_general_environments())
|
||||
|
||||
# first resolve general environment because merge doesn't expect
|
||||
# values to be list.
|
||||
# TODO: switch to AYON environment functions
|
||||
merged_env = acre.merge(
|
||||
acre.compute(acre.parse(general_env), cleanup=False),
|
||||
# Merge environments with current environments and update values
|
||||
merged_env = merge_env_variables(
|
||||
compute_env_variables_structure(general_env),
|
||||
dict(os.environ)
|
||||
)
|
||||
env = acre.compute(
|
||||
merged_env,
|
||||
cleanup=False
|
||||
)
|
||||
env = compute_env_variables_structure(merged_env)
|
||||
os.environ.clear()
|
||||
os.environ.update(env)
|
||||
|
||||
|
|
@ -263,8 +263,8 @@ def _set_addons_environments(addons_manager):
|
|||
|
||||
# Merge environments with current environments and update values
|
||||
if module_envs := addons_manager.collect_global_environments():
|
||||
parsed_envs = acre.parse(module_envs)
|
||||
env = acre.merge(parsed_envs, dict(os.environ))
|
||||
parsed_envs = parse_env_variables_structure(module_envs)
|
||||
env = merge_env_variables(parsed_envs, dict(os.environ))
|
||||
os.environ.clear()
|
||||
os.environ.update(env)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,34 @@
|
|||
from __future__ import annotations
|
||||
import os
|
||||
import re
|
||||
import platform
|
||||
import typing
|
||||
import collections
|
||||
from string import Formatter
|
||||
from typing import Optional
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from typing import Union, Literal
|
||||
|
||||
PlatformName = Literal["windows", "linux", "darwin"]
|
||||
EnvValue = Union[str, list[str], dict[str, str], dict[str, list[str]]]
|
||||
|
||||
|
||||
def env_value_to_bool(env_key=None, value=None, default=False):
|
||||
class CycleError(ValueError):
|
||||
"""Raised when a cycle is detected in dynamic env variables compute."""
|
||||
pass
|
||||
|
||||
|
||||
class DynamicKeyClashError(Exception):
|
||||
"""Raised when dynamic key clashes with an existing key."""
|
||||
pass
|
||||
|
||||
|
||||
def env_value_to_bool(
|
||||
env_key: Optional[str] = None,
|
||||
value: Optional[str] = None,
|
||||
default: bool = False,
|
||||
) -> bool:
|
||||
"""Convert environment variable value to boolean.
|
||||
|
||||
Function is based on value of the environemt variable. Value is lowered
|
||||
|
|
@ -11,6 +38,7 @@ def env_value_to_bool(env_key=None, value=None, default=False):
|
|||
bool: If value match to one of ["true", "yes", "1"] result if True
|
||||
but if value match to ["false", "no", "0"] result is False else
|
||||
default value is returned.
|
||||
|
||||
"""
|
||||
if value is None and env_key is None:
|
||||
return default
|
||||
|
|
@ -27,18 +55,23 @@ def env_value_to_bool(env_key=None, value=None, default=False):
|
|||
return default
|
||||
|
||||
|
||||
def get_paths_from_environ(env_key=None, env_value=None, return_first=False):
|
||||
def get_paths_from_environ(
|
||||
env_key: Optional[str] = None,
|
||||
env_value: Optional[str] = None,
|
||||
return_first: bool = False,
|
||||
) -> Optional[Union[str, list[str]]]:
|
||||
"""Return existing paths from specific environment variable.
|
||||
|
||||
Args:
|
||||
env_key (str): Environment key where should look for paths.
|
||||
env_value (str): Value of environment variable. Argument `env_key` is
|
||||
skipped if this argument is entered.
|
||||
env_key (Optional[str]): Environment key where should look for paths.
|
||||
env_value (Optional[str]): Value of environment variable.
|
||||
Argument `env_key` is skipped if this argument is entered.
|
||||
return_first (bool): Return first found value or return list of found
|
||||
paths. `None` or empty list returned if nothing found.
|
||||
|
||||
Returns:
|
||||
str, list, None: Result of found path/s.
|
||||
Optional[Union[str, list[str]]]: Result of found path/s.
|
||||
|
||||
"""
|
||||
existing_paths = []
|
||||
if not env_key and not env_value:
|
||||
|
|
@ -69,3 +102,225 @@ def get_paths_from_environ(env_key=None, env_value=None, return_first=False):
|
|||
return None
|
||||
# Return all existing paths from environment variable
|
||||
return existing_paths
|
||||
|
||||
|
||||
def parse_env_variables_structure(
|
||||
env: dict[str, EnvValue],
|
||||
platform_name: Optional[PlatformName] = None
|
||||
) -> dict[str, str]:
|
||||
"""Parse environment for platform-specific values and paths as lists.
|
||||
|
||||
Args:
|
||||
env (dict): The source environment to read.
|
||||
platform_name (Optional[PlatformName]): Name of platform to parse for.
|
||||
Defaults to current platform.
|
||||
|
||||
Returns:
|
||||
dict: The flattened environment for a platform.
|
||||
|
||||
"""
|
||||
if platform_name is None:
|
||||
platform_name = platform.system().lower()
|
||||
|
||||
# Separator based on OS 'os.pathsep' is ';' on Windows and ':' on Unix
|
||||
sep = ";" if platform_name == "windows" else ":"
|
||||
|
||||
result = {}
|
||||
for variable, value in env.items():
|
||||
# Platform specific values
|
||||
if isinstance(value, dict):
|
||||
value = value.get(platform_name)
|
||||
|
||||
# Allow to have lists as values in the tool data
|
||||
if isinstance(value, (list, tuple)):
|
||||
value = sep.join(value)
|
||||
|
||||
if not value:
|
||||
continue
|
||||
|
||||
if not isinstance(value, str):
|
||||
raise TypeError(f"Expected 'str' got '{type(value)}'")
|
||||
|
||||
result[variable] = value
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _topological_sort(
|
||||
dependencies: dict[str, set[str]]
|
||||
) -> tuple[list[str], list[str]]:
|
||||
"""Sort values subject to dependency constraints.
|
||||
|
||||
Args:
|
||||
dependencies (dict[str, set[str]): Mapping of environment variable
|
||||
keys to a set of keys they depend on.
|
||||
|
||||
Returns:
|
||||
tuple[list[str], list[str]]: A tuple of two lists. The first list
|
||||
contains the ordered keys in which order should be environment
|
||||
keys filled, the second list contains the keys that would cause
|
||||
cyclic fill of values.
|
||||
|
||||
"""
|
||||
num_heads = collections.defaultdict(int) # num arrows pointing in
|
||||
tails = collections.defaultdict(list) # list of arrows going out
|
||||
heads = [] # unique list of heads in order first seen
|
||||
for head, tail_values in dependencies.items():
|
||||
for tail_value in tail_values:
|
||||
num_heads[tail_value] += 1
|
||||
if head not in tails:
|
||||
heads.append(head)
|
||||
tails[head].append(tail_value)
|
||||
|
||||
ordered = [head for head in heads if head not in num_heads]
|
||||
for head in ordered:
|
||||
for tail in tails[head]:
|
||||
num_heads[tail] -= 1
|
||||
if not num_heads[tail]:
|
||||
ordered.append(tail)
|
||||
cyclic = [tail for tail, heads in num_heads.items() if heads]
|
||||
return ordered, cyclic
|
||||
|
||||
|
||||
class _PartialFormatDict(dict):
|
||||
"""This supports partial formatting.
|
||||
|
||||
Missing keys are replaced with the return value of __missing__.
|
||||
|
||||
"""
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self._missing_template: str = "{{{key}}}"
|
||||
|
||||
def set_missing_template(self, template: str):
|
||||
self._missing_template = template
|
||||
|
||||
def __missing__(self, key: str) -> str:
|
||||
return self._missing_template.format(key=key)
|
||||
|
||||
|
||||
def _partial_format(
|
||||
value: str,
|
||||
data: dict[str, str],
|
||||
missing_template: Optional[str] = None,
|
||||
) -> str:
|
||||
"""Return string `s` formatted by `data` allowing a partial format
|
||||
|
||||
Arguments:
|
||||
value (str): The string that will be formatted
|
||||
data (dict): The dictionary used to format with.
|
||||
missing_template (Optional[str]): The template to use when a key is
|
||||
missing from the data. If `None`, the key will remain unformatted.
|
||||
|
||||
Example:
|
||||
>>> _partial_format("{d} {a} {b} {c} {d}", {'b': "and", 'd': "left"})
|
||||
'left {a} and {c} left'
|
||||
|
||||
"""
|
||||
|
||||
mapping = _PartialFormatDict(**data)
|
||||
if missing_template is not None:
|
||||
mapping.set_missing_template(missing_template)
|
||||
|
||||
formatter = Formatter()
|
||||
try:
|
||||
output = formatter.vformat(value, (), mapping)
|
||||
except Exception:
|
||||
r_token = re.compile(r"({.*?})")
|
||||
output = value
|
||||
for match in re.findall(r_token, value):
|
||||
try:
|
||||
output = re.sub(match, match.format(**data), output)
|
||||
except (KeyError, ValueError, IndexError):
|
||||
continue
|
||||
return output
|
||||
|
||||
|
||||
def compute_env_variables_structure(
|
||||
env: dict[str, str],
|
||||
fill_dynamic_keys: bool = True,
|
||||
) -> dict[str, str]:
|
||||
"""Compute the result from recursive dynamic environment.
|
||||
|
||||
Note: Keys that are not present in the data will remain unformatted as the
|
||||
original keys. So they can be formatted against the current user
|
||||
environment when merging. So {"A": "{key}"} will remain {key} if not
|
||||
present in the dynamic environment.
|
||||
|
||||
"""
|
||||
env = env.copy()
|
||||
|
||||
# Collect dependencies
|
||||
dependencies = collections.defaultdict(set)
|
||||
for key, value in env.items():
|
||||
dependent_keys = re.findall("{(.+?)}", value)
|
||||
for dependent_key in dependent_keys:
|
||||
# Ignore reference to itself or key is not in env
|
||||
if dependent_key != key and dependent_key in env:
|
||||
dependencies[key].add(dependent_key)
|
||||
|
||||
ordered, cyclic = _topological_sort(dependencies)
|
||||
|
||||
# Check cycle
|
||||
if cyclic:
|
||||
raise CycleError(f"A cycle is detected on: {cyclic}")
|
||||
|
||||
# Format dynamic values
|
||||
for key in reversed(ordered):
|
||||
if key in env:
|
||||
if not isinstance(env[key], str):
|
||||
continue
|
||||
data = env.copy()
|
||||
data.pop(key) # format without itself
|
||||
env[key] = _partial_format(env[key], data=data)
|
||||
|
||||
# Format dynamic keys
|
||||
if fill_dynamic_keys:
|
||||
formatted = {}
|
||||
for key, value in env.items():
|
||||
if not isinstance(value, str):
|
||||
formatted[key] = value
|
||||
continue
|
||||
|
||||
new_key = _partial_format(key, data=env)
|
||||
if new_key in formatted:
|
||||
raise DynamicKeyClashError(
|
||||
f"Key clashes on: {new_key} (source: {key})"
|
||||
)
|
||||
|
||||
formatted[new_key] = value
|
||||
env = formatted
|
||||
|
||||
return env
|
||||
|
||||
|
||||
def merge_env_variables(
|
||||
src_env: dict[str, str],
|
||||
dst_env: dict[str, str],
|
||||
missing_template: Optional[str] = None,
|
||||
) -> dict[str, str]:
|
||||
"""Merge the tools environment with the 'current_env'.
|
||||
|
||||
This finalizes the join with a current environment by formatting the
|
||||
remainder of dynamic variables with that from the current environment.
|
||||
|
||||
Remaining missing variables result in an empty value.
|
||||
|
||||
Args:
|
||||
src_env (dict): The dynamic environment
|
||||
dst_env (dict): The target environment variables mapping to merge
|
||||
the dynamic environment into.
|
||||
missing_template (str): Argument passed to '_partial_format' during
|
||||
merging. `None` should keep missing keys unchanged.
|
||||
|
||||
Returns:
|
||||
dict[str, str]: The resulting environment after the merge.
|
||||
|
||||
"""
|
||||
result = dst_env.copy()
|
||||
for key, value in src_env.items():
|
||||
result[key] = _partial_format(
|
||||
str(value), dst_env, missing_template
|
||||
)
|
||||
|
||||
return result
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ from ayon_core.pipeline.plugin_discover import (
|
|||
from ayon_core.pipeline.create import (
|
||||
discover_legacy_creator_plugins,
|
||||
CreateContext,
|
||||
HiddenCreator,
|
||||
)
|
||||
|
||||
_NOT_SET = object()
|
||||
|
|
@ -309,7 +310,13 @@ class AbstractTemplateBuilder(ABC):
|
|||
self._creators_by_name = creators_by_name
|
||||
|
||||
def _collect_creators(self):
|
||||
self._creators_by_name = dict(self.create_context.creators)
|
||||
self._creators_by_name = {
|
||||
identifier: creator
|
||||
for identifier, creator
|
||||
in self.create_context.manual_creators.items()
|
||||
# Do not list HiddenCreator even though it is a 'manual creator'
|
||||
if not isinstance(creator, HiddenCreator)
|
||||
}
|
||||
|
||||
def get_creators_by_name(self):
|
||||
if self._creators_by_name is None:
|
||||
|
|
|
|||
|
|
@ -116,11 +116,11 @@ class CollectAnatomyInstanceData(pyblish.api.ContextPlugin):
|
|||
|
||||
if not_found_folder_paths:
|
||||
joined_folder_paths = ", ".join(
|
||||
["\"{}\"".format(path) for path in not_found_folder_paths]
|
||||
[f"\"{path}\"" for path in not_found_folder_paths]
|
||||
)
|
||||
self.log.warning(
|
||||
f"Not found folder entities with paths {joined_folder_paths}."
|
||||
)
|
||||
self.log.warning((
|
||||
"Not found folder entities with paths \"{}\"."
|
||||
).format(joined_folder_paths))
|
||||
|
||||
def fill_missing_task_entities(self, context, project_name):
|
||||
self.log.debug("Querying task entities for instances.")
|
||||
|
|
|
|||
|
|
@ -286,7 +286,7 @@ class ExtractOTIOReview(
|
|||
)
|
||||
|
||||
instance.data["representations"].append(representation)
|
||||
self.log.info("Adding representation: {}".format(representation))
|
||||
self.log.debug("Adding representation: {}".format(representation))
|
||||
|
||||
def _create_representation(self, start, duration):
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
from operator import attrgetter
|
||||
import dataclasses
|
||||
import os
|
||||
from typing import Dict
|
||||
from typing import Any, Dict, List
|
||||
|
||||
import pyblish.api
|
||||
try:
|
||||
|
|
@ -14,7 +14,8 @@ from ayon_core.lib import (
|
|||
BoolDef,
|
||||
UISeparatorDef,
|
||||
UILabelDef,
|
||||
EnumDef
|
||||
EnumDef,
|
||||
filter_profiles
|
||||
)
|
||||
try:
|
||||
from ayon_core.pipeline.usdlib import (
|
||||
|
|
@ -281,6 +282,9 @@ class CollectUSDLayerContributions(pyblish.api.InstancePlugin,
|
|||
"fx": 500,
|
||||
"lighting": 600,
|
||||
}
|
||||
# Default profiles to set certain instance attribute defaults based on
|
||||
# profiles in settings
|
||||
profiles: List[Dict[str, Any]] = []
|
||||
|
||||
@classmethod
|
||||
def apply_settings(cls, project_settings):
|
||||
|
|
@ -298,6 +302,8 @@ class CollectUSDLayerContributions(pyblish.api.InstancePlugin,
|
|||
if contribution_layers:
|
||||
cls.contribution_layers = contribution_layers
|
||||
|
||||
cls.profiles = plugin_settings.get("profiles", [])
|
||||
|
||||
def process(self, instance):
|
||||
|
||||
attr_values = self.get_attr_values_from_data(instance.data)
|
||||
|
|
@ -463,6 +469,28 @@ class CollectUSDLayerContributions(pyblish.api.InstancePlugin,
|
|||
if not cls.instance_matches_plugin_families(instance):
|
||||
return []
|
||||
|
||||
# Set default target layer based on product type
|
||||
current_context_task_type = create_context.get_current_task_type()
|
||||
profile = filter_profiles(cls.profiles, {
|
||||
"product_types": instance.data["productType"],
|
||||
"task_types": current_context_task_type
|
||||
})
|
||||
if not profile:
|
||||
profile = {}
|
||||
|
||||
# Define defaults
|
||||
default_contribution_layer = profile.get(
|
||||
"contribution_layer", None)
|
||||
default_apply_as_variant = profile.get(
|
||||
"contribution_apply_as_variant", False)
|
||||
default_target_product = profile.get(
|
||||
"contribution_target_product", "usdAsset")
|
||||
default_init_as = (
|
||||
"asset"
|
||||
if profile.get("contribution_target_product") == "usdAsset"
|
||||
else "shot")
|
||||
init_as_visible = False
|
||||
|
||||
# Attributes logic
|
||||
publish_attributes = instance["publish_attributes"].get(
|
||||
cls.__name__, {})
|
||||
|
|
@ -495,7 +523,7 @@ class CollectUSDLayerContributions(pyblish.api.InstancePlugin,
|
|||
"the contribution itself will be added to the "
|
||||
"department layer."
|
||||
),
|
||||
default="usdAsset",
|
||||
default=default_target_product,
|
||||
visible=visible),
|
||||
EnumDef("contribution_target_product_init",
|
||||
label="Initialize as",
|
||||
|
|
@ -507,8 +535,8 @@ class CollectUSDLayerContributions(pyblish.api.InstancePlugin,
|
|||
"setting will do nothing."
|
||||
),
|
||||
items=["asset", "shot"],
|
||||
default="asset",
|
||||
visible=visible),
|
||||
default=default_init_as,
|
||||
visible=visible and init_as_visible),
|
||||
|
||||
# Asset layer, e.g. model.usd, look.usd, rig.usd
|
||||
EnumDef("contribution_layer",
|
||||
|
|
@ -520,7 +548,7 @@ class CollectUSDLayerContributions(pyblish.api.InstancePlugin,
|
|||
"the list) will contribute as a stronger opinion."
|
||||
),
|
||||
items=list(cls.contribution_layers.keys()),
|
||||
default="model",
|
||||
default=default_contribution_layer,
|
||||
visible=visible),
|
||||
BoolDef("contribution_apply_as_variant",
|
||||
label="Add as variant",
|
||||
|
|
@ -532,7 +560,7 @@ class CollectUSDLayerContributions(pyblish.api.InstancePlugin,
|
|||
"appended to as a sublayer to the department layer "
|
||||
"instead."
|
||||
),
|
||||
default=True,
|
||||
default=default_apply_as_variant,
|
||||
visible=visible),
|
||||
TextDef("contribution_variant_set_name",
|
||||
label="Variant Set Name",
|
||||
|
|
@ -588,31 +616,6 @@ class CollectUSDLayerContributions(pyblish.api.InstancePlugin,
|
|||
instance.set_publish_plugin_attr_defs(cls.__name__, new_attrs)
|
||||
|
||||
|
||||
class CollectUSDLayerContributionsHoudiniLook(CollectUSDLayerContributions):
|
||||
"""
|
||||
This is solely here to expose the attribute definitions for the
|
||||
Houdini "look" family.
|
||||
"""
|
||||
# TODO: Improve how this is built for the look family
|
||||
hosts = ["houdini"]
|
||||
families = ["look"]
|
||||
label = CollectUSDLayerContributions.label + " (Look)"
|
||||
|
||||
@classmethod
|
||||
def get_attr_defs_for_instance(cls, create_context, instance):
|
||||
# Filtering of instance, if needed, can be customized
|
||||
if not cls.instance_matches_plugin_families(instance):
|
||||
return []
|
||||
|
||||
defs = super().get_attr_defs_for_instance(create_context, instance)
|
||||
|
||||
# Update default for department layer to look
|
||||
layer_def = next(d for d in defs if d.key == "contribution_layer")
|
||||
layer_def.default = "look"
|
||||
|
||||
return defs
|
||||
|
||||
|
||||
class ValidateUSDDependencies(pyblish.api.InstancePlugin):
|
||||
families = ["usdLayer"]
|
||||
|
||||
|
|
|
|||
|
|
@ -31,7 +31,6 @@ from . import settings, util
|
|||
from .awesome import tags as awesome
|
||||
from qtpy import QtCore, QtGui
|
||||
import qtawesome
|
||||
from six import text_type
|
||||
from .constants import PluginStates, InstanceStates, GroupStates, Roles
|
||||
|
||||
|
||||
|
|
@ -985,7 +984,7 @@ class TerminalModel(QtGui.QStandardItemModel):
|
|||
record_item = record
|
||||
else:
|
||||
record_item = {
|
||||
"label": text_type(record.msg),
|
||||
"label": str(record.msg),
|
||||
"type": "record",
|
||||
"levelno": record.levelno,
|
||||
"threadName": record.threadName,
|
||||
|
|
@ -993,7 +992,7 @@ class TerminalModel(QtGui.QStandardItemModel):
|
|||
"filename": record.filename,
|
||||
"pathname": record.pathname,
|
||||
"lineno": record.lineno,
|
||||
"msg": text_type(record.msg),
|
||||
"msg": str(record.msg),
|
||||
"msecs": record.msecs,
|
||||
"levelname": record.levelname
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ import sys
|
|||
import collections
|
||||
|
||||
from qtpy import QtCore
|
||||
from six import text_type
|
||||
import pyblish.api
|
||||
|
||||
root = os.path.dirname(__file__)
|
||||
|
|
@ -64,7 +63,7 @@ def u_print(msg, **kwargs):
|
|||
**kwargs: Keyword argument for `print` function.
|
||||
"""
|
||||
|
||||
if isinstance(msg, text_type):
|
||||
if isinstance(msg, str):
|
||||
encoding = None
|
||||
try:
|
||||
encoding = os.getenv('PYTHONIOENCODING', sys.stdout.encoding)
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ from __future__ import print_function
|
|||
import json
|
||||
import os
|
||||
|
||||
import six
|
||||
from qtpy import QtCore, QtGui
|
||||
|
||||
|
||||
|
|
@ -152,7 +151,7 @@ class IconicFont(QtCore.QObject):
|
|||
def hook(obj):
|
||||
result = {}
|
||||
for key in obj:
|
||||
result[key] = six.unichr(int(obj[key], 16))
|
||||
result[key] = chr(int(obj[key], 16))
|
||||
return result
|
||||
|
||||
if directory is None:
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""Package declaring AYON addon 'core' version."""
|
||||
__version__ = "1.1.0+dev"
|
||||
__version__ = "1.1.1+dev"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue