Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Messages Internationalization #132

Open
croxarens opened this issue Oct 29, 2020 · 38 comments
Open

Messages Internationalization #132

croxarens opened this issue Oct 29, 2020 · 38 comments

Comments

@croxarens
Copy link

Hi there,

is there any way I can translate the messages like "Request Camera Permissions", "Scan an Image File"?

@cheweytoo
Copy link

Can I help?

https://github.com/mebjas/html5-qrcode/blob/master/src/strings.ts already separates the UI strings (and considers internationalization a TODO :-), so this looks well prepared for.

@mebjas: Have you already thought about how you'd want the internationalization to be triggered/configured? Via an additional config option maybe?

It would of course be nice to just automatically match the first language in navigator.languages that is supported by html5-qrcode's translations, but I don't know if this can be relied on to be available in all environments supported by html5-qrcode.

@mebjas
Copy link
Owner

mebjas commented Aug 9, 2021

@cheweytoo Thanks for the interest. This has been a key issue in my mind.

Have you already thought about how you'd want the internationalisation to be triggered/configured? Via an additional config option maybe?

I am happy to hear your thoughts on this topic. Different languages baked into the JS code with a config deciding the language is definitely one option. In case of missing language support it could fallback to English. I have not worked on projects where this is purely frontend based.

An alternative that comes to mind is generating different JS files for diff languages but that seems like a huge pain for consumers.

The design in my mind is:

| src 
......| strings.ts (become an interface only)
......| strings-factory.ts
......| strings
..................| strings.en.ts
..................| strings.fr.ts
..................| (all diff languages)

strings-factory.ts could either consume argument like this:

enum StringMode {
    ENGLISH_ONLY,  // default
    AUTO_DETECT,
    EXPLICIT
}

interface StringsConfig {
   stringMode: StringMode;
   language?: string; // only honoured when stringMode is 'EXPLICIT'.
}

And return the impl or default to english if the language is not implemented. Based on contributors we can keep adding more strings.

@jpaoletti
Copy link

Hi all, nice library !

Is there a way to manually set the texts at render or creation time ? I'd like to use Html5QrcodeScanner but I really need it translated. Some workaround comes to your mind?

Thanks!

@croxarens
Copy link
Author

Hi @mebjas, I'm mostly a backend developer, and I have just general knowledge of JS.
My suggestion, or at least my point of view, is to allow everyone to easily change/update/amend the language file without the need to compile something.
So, for this reason, I'd stay with some basic JS options, like a file with a single object called lang and each property as a word/phrase. I think this should allow some sort of easy attribute injection too.

lang = { hello : "ciao" }

Hi @jpaoletti, I just looked for the phrase I want to translate in the codebase, and just changed it in the code. Of course this solution doesn't work with multilanguage applications.

@jpaoletti
Copy link

@croxarens Yeah I ended up doing that but it is not ideal. I agree with your proposed solution

@faustort
Copy link

@cheweytoo Thanks for the interest. This has been a key issue in my mind.

Have you already thought about how you'd want the internationalisation to be triggered/configured? Via an additional config option maybe?

I am happy to hear your thoughts on this topic. Different languages baked into the JS code with a config deciding the language is definitely one option. In case of missing language support it could fallback to English. I have not worked on projects where this is purely frontend based.

An alternative that comes to mind is generating different JS files for diff languages but that seems like a huge pain for consumers.

The design in my mind is:

| src 
......| strings.ts (become an interface only)
......| strings-factory.ts
......| strings
..................| strings.en.ts
..................| strings.fr.ts
..................| (all diff languages)

strings-factory.ts could either consume argument like this:

enum StringMode {
    ENGLISH_ONLY,  // default
    AUTO_DETECT,
    EXPLICIT
}

interface StringsConfig {
   stringMode: StringMode;
   language?: string; // only honoured when stringMode is 'EXPLICIT'.
}

And return the impl or default to english if the language is not implemented. Based on contributors we can keep adding more strings.

If you start the:
......| strings.ts (become an interface only)
......| strings-factory.ts

I'll be glad to help up create
..................| strings.pt-pt.ts
..................| strings.pt-br.ts
..................| strings.es.ts

Btw thanks for this amazing work @mebjas

@IlyaDiallo
Copy link

Hi @mebjas, I'm mostly a backend developer, and I have just general knowledge of JS. My suggestion, or at least my point of view, is to allow everyone to easily change/update/amend the language file without the need to compile something. So, for this reason, I'd stay with some basic JS options, like a file with a single object called lang and each property as a word/phrase. I think this should allow some sort of easy attribute injection too.

lang = { hello : "ciao" }

Yes string injection at runtime is the most versatile, no need to hardcode translation then (but it's possible to do both).

@mebjas
Copy link
Owner

mebjas commented Feb 19, 2022

@mebjas

  • Start an effort on this with Spanish and French
  • Write a blog post describing how to do this for any language.
  • Request contributors to add support for more languages within the library

@AymaneSilini
Copy link

AymaneSilini commented Jul 7, 2022

Hi all. I am currently working on a React scanning app. Did you find a way to efficacely change language of elements ?
Thank you.

@mebjas
Copy link
Owner

mebjas commented Nov 17, 2022

Will look into this soon.

@bonino97
Copy link

Will look into this soon.

we will be very grateful. :)

@mebjas
Copy link
Owner

mebjas commented Nov 19, 2022 via email

@IlyaDiallo
Copy link

Similarly, with string injection let's say you maintain your own strings - new changes to api can break your version because of missing string. Any ideas on how to address these issues?

API changes should not break a translated version. At worst, any missing translation should default to English.

@AlfonsoML
Copy link
Contributor

The system should fallback to English if there's anything missing, that way nothing breaks and new releases aren't delayed waiting for the update of every translation.
It can be done by javascript (take missing entries from the English version or augment the English version with the entries in the translation), or by copying in every translation file the new English sentences.

@ROBERT-MCDOWELL
Copy link

let the developer see in the console log the missing string.....

@mebjas
Copy link
Owner

mebjas commented Nov 19, 2022

Got it, it makes sense to me.

We would still need a process to keep updating the strings and string changes to as many languages as possible.

@IlyaDiallo
Copy link

Got it, it makes sense to me.

We would still need a process to keep updating the strings and string changes to as many languages as possible.

The most important step is to provide a mean for the lib users to inject the translations. Including the translations in the lib itself is a maintenance burden that you may want to avoid. Also, aside of the many languages, some may want to tweak the messages to better suit their use case (more or less verbose for instance).

@cheweytoo
Copy link

Including the translations in the lib itself is a maintenance burden that you may want to avoid.

On the other hand, developers don't tend to speak all the languages. So an ability to overrule or inject own translations is fine, but having existing translations (maybe in a separate repo) would be extremely helpful. It would also avoid an awful lot of duplicated translation work.

@cheweytoo
Copy link

We would still need a process to keep updating the strings and string changes to as many languages as possible.

One way to do that is to use versioning (think versioned API endpoints): Loading an outdated translation set would give a console message. This would make developers aware of the issue, and motivate them to contribute updates.

@mebjas mebjas mentioned this issue Nov 30, 2022
@1sahinomer1
Copy link

Hi @mebjas ,

Any chance to update https://github.com/scanapp-org/html5-qrcode-react with the language string example

I couldn't do this integration on my own, I think it would be more explanatory

Thank you

@1sahinomer1
Copy link

1sahinomer1 commented Dec 14, 2022

I translated it for Turkish and this is how I found a solution for now.

 #html5qr-code-full-region {
    img[alt="Info icon"] {
      display: none;
    }
  }
  #html5-qrcode-button-camera-permission {
    text-indent: -9999px;
    line-height: 0;
    margin-bottom: 10px;
  }
  #html5-qrcode-button-camera-permission:after {
    content: "Kamera izni talep et";
    text-indent: 0;
    display: block;
    line-height: initial;
  }
  #html5-qrcode-anchor-scan-type-change {
    font-size: 0;
  }
  #html5-qrcode-anchor-scan-type-change:after {
    font-size: 1rem;
    content: "Tarama tipini değiştir";
    cursor: pointer;
  }
  #html5-qrcode-button-file-selection {
    text-indent: -9999px;
    line-height: 0;
    margin-bottom: 0 !important;
  }
  #html5-qrcode-button-file-selection:after {
    content: "Dosya seç";
    text-indent: 0;
    display: block;
    line-height: initial;
  }

  #html5qr-code-full-region__dashboard_section {
    div:first-of-type {
      div:last-of-type {
        div {
          text-indent: -9999px;
          line-height: 0;
        }
        div:after {
          content: "Fotoğrafı sürükleyip bırakabilirsiniz.";
          text-indent: 0;
          display: block;
          line-height: initial;
        }
      }
    }
  }
  #html5qr-code-full-region__header_message {
    text-indent: -9999px;
    line-height: 0;
  }
  #html5qr-code-full-region__header_message:after {
    content: "Yüklenen fotoğrafta QR kod okunmuyor lütfen kırpıp yükleyiniz.";
    text-indent: 0;
    display: block;
    line-height: initial;
  }

@spivurno
Copy link

+1 for this! Would love to to discuss sponsoring the work.

@mebjas
Copy link
Owner

mebjas commented Mar 18, 2023

Sounds good, that'd be helpful - this issue is pretty high up in my radar.

I would like to learn more about the use cases - please DM at minhazav@gmail.com

Re: sponsorship

Checkout https://ko-fi.com/minhazav/tiers - this would definitely help make maintenance more sustainable!

@patocardo
Copy link

Based on the workaround of @1sahinomer1 , I developed the following dynamic code that observes the changes that the library does. It is working on Vue, and it should work on vanilla.

/**
 * This is a Workaround to translate the interface, because as of 2023-03-31,
 * Html5QrcodeScanner the library doesn't support I18N
 * Feel free to remove this piece of anti-pattern once the library can translate
 * by itself
 * 
 * Note that as there are inner interaction, text must be replaced on the fly
 */

/**
 * It observe certain selectors to overwrite with custom texts
 * @param {HTMLElement} ref
 * @returns {void}
 */
export default function scannerTranslator(ref) {
  const mappingArray = [
    { 
      selector: '#html5-qrcode-button-camera-permission',
      text: 'Solicitar permiso de cámara'
    },
    {
      selector: '#html5-qrcode-anchor-scan-type-change',
      text: 'Cambiar el tipo de escaneo'
    },
    {
      selector: '#html5-qrcode-button-file-selection',
      text: 'Seleccionar archivo'
    },
    {
      selector: '#reader__dashboard_section > div:nth-child(1) > div:nth-child(2) > div:nth-child(2)',
      text: 'Puedes arrastrar y soltar la foto'
    },
    {
      selector: '#html5qr-code-full-region__header_message',
      text: 'No se puede leer el código QR en la foto cargada. Recorta y vuelve a cargar'
    },
  ];

  // Options for the observer (which mutations to observe)
  const config = { childList: true, subtree: true };

  // Create an observer instance linked to the callback function
  const observer = new MutationObserver(function(mutationsList) {
    for(let mutation of mutationsList) {
      if (mutation.type === 'childList') {
        mappingArray.forEach((item) => {
          const element = ref.querySelector(item.selector);
          if (element && element.textContent !== item.text) {
            element.textContent = item.text;
          }
        });
      }
    }
  });

  // Start observing the target node for configured mutations
  observer.observe(ref, config);
}

alvedder added a commit to alvedder/html5-qrcode that referenced this issue Apr 23, 2023
alvedder added a commit to alvedder/html5-qrcode that referenced this issue Apr 23, 2023
alvedder added a commit to alvedder/html5-qrcode that referenced this issue Apr 23, 2023
@alvedder
Copy link

Hi! I made a PR, can you please check it?

@sanddy
Copy link

sanddy commented Apr 25, 2023

Bonjour,
Pour ma part, du côté français, j'ai juste traduit les deux boutons ainsi dans ma vue :


<style>
          #html5-qrcode-button-camera-start {font-size:0}
          #html5-qrcode-button-camera-start::after {
            content:"Scanner";
            font-size:initial;
          }
          #html5-qrcode-button-camera-stop {font-size:0}
          #html5-qrcode-button-camera-stop::after {
            content:"Arrêter le scan";
            font-size:initial;
          }
        </style>

et cela permet d'avoir la traduction comme je souhaite au moins ;)

@hagenholm
Copy link

English: Since the owner does not want to make any changes for the translations I do it without changing the original code. I have created my own code and it works great.

Spanish: En vista que el dueño no quiere realizar ningun cambio para las traducciones yo lo hago sin cambiar el codigo original. He creado mi propio codigo y funciona de maravilla.


function scannerTranslator() {
	const traducciones = [
		// Html5QrcodeStrings
		{original: "QR code parse error, error =", traduccion: "Error al analizar el código QR, error ="},
		{original: "Error getting userMedia, error =", traduccion: "Error al obtener userMedia, error ="},
		{original: "The device doesn't support navigator.mediaDevices , only supported cameraIdOrConfig in this case is deviceId parameter (string).", traduccion: "El dispositivo no admite navigator.mediaDevices, en este caso sólo se admite cameraIdOrConfig como parámetro deviceId (cadena)."},
		{original: "Camera streaming not supported by the browser.", traduccion: "El navegador no admite la transmisión de la cámara."},
		{original: "Unable to query supported devices, unknown error.", traduccion: "No se puede consultar los dispositivos compatibles, error desconocido."},
		{original: "Camera access is only supported in secure context like https or localhost.", traduccion: "El acceso a la cámara sólo es compatible en un contexto seguro como https o localhost."},
		{original: "Scanner paused", traduccion: "Escáner en pausa"},
	
		// Html5QrcodeScannerStrings
		{original: "Scanning", traduccion: "Escaneando"},
		{original: "Idle", traduccion: "Inactivo"},
		{original: "Error", traduccion: "Error"},
		{original: "Permission", traduccion: "Permiso"},
		{original: "No Cameras", traduccion: "Sin cámaras"},
		{original: "Last Match:", traduccion: "Última coincidencia:"},
		{original: "Code Scanner", traduccion: "Escáner de código"},
		{original: "Request Camera Permissions", traduccion: "Solicitar permisos de cámara"},
		{original: "Requesting camera permissions...", traduccion: "Solicitando permisos de cámara..."},
		{original: "No camera found", traduccion: "No se encontró ninguna cámara"},
		{original: "Stop Scanning", traduccion: "Detener escaneo"},
		{original: "Start Scanning", traduccion: "Iniciar escaneo"},
		{original: "Switch On Torch", traduccion: "Encender linterna"},
		{original: "Switch Off Torch", traduccion: "Apagar linterna"},
		{original: "Failed to turn on torch", traduccion: "Error al encender la linterna"},
		{original: "Failed to turn off torch", traduccion: "Error al apagar la linterna"},
		{original: "Launching Camera...", traduccion: "Iniciando cámara..."},
		{original: "Scan an Image File", traduccion: "Escanear un archivo de imagen"},
		{original: "Scan using camera directly", traduccion: "Escanear usando la cámara directamente"},
		{original: "Select Camera", traduccion: "Seleccionar cámara"},
		{original: "Choose Image", traduccion: "Elegir imagen"},
		{original: "Choose Another", traduccion: "Elegir otra"},
		{original: "No image choosen", traduccion: "Ninguna imagen seleccionada"},
		{original: "Anonymous Camera", traduccion: "Cámara anónima"},
		{original: "Or drop an image to scan", traduccion: "O arrastra una imagen para escanear"},
		{original: "Or drop an image to scan (other files not supported)", traduccion: "O arrastra una imagen para escanear (otros archivos no soportados)"},
		{original: "zoom", traduccion: "zoom"},
		{original: "Loading image...", traduccion: "Cargando imagen..."},
		{original: "Camera based scan", traduccion: "Escaneo basado en cámara"},
		{original: "Fule based scan", traduccion: "Escaneo basado en archivo"},

		// LibraryInfoStrings
		{original: "Powered by ", traduccion: "Desarrollado por "},
		{original: "Report issues", traduccion: "Informar de problemas"},

		// Others
		{original: "NotAllowedError: Permission denied", traduccion: "Permiso denegado para acceder a la cámara"}
	];

	// Función para traducir un texto
	function traducirTexto(texto) {
		const traduccion = traducciones.find(t => t.original === texto);
		return traduccion ? traduccion.traduccion : texto;
	}

	// Función para traducir los nodos de texto
	function traducirNodosDeTexto(nodo) {
		if (nodo.nodeType === Node.TEXT_NODE) {
			nodo.textContent = traducirTexto(nodo.textContent.trim());
		} else {
			for (let i = 0; i < nodo.childNodes.length; i++) {
				traducirNodosDeTexto(nodo.childNodes[i]);
			}
		}
	}

	// Crear el MutationObserver
	const observer = new MutationObserver((mutations) => {
		mutations.forEach((mutation) => {
			if (mutation.type === 'childList') {
				mutation.addedNodes.forEach((nodo) => {
					traducirNodosDeTexto(nodo);
				});
			}
		});
	});

	// Configurar y ejecutar el observer
	const config = {childList: true, subtree: true};
	observer.observe(document.body, config);

	// Traducir el contenido inicial
	traducirNodosDeTexto(document.body);
}

document.addEventListener('DOMContentLoaded', function () {
// Utilizando la función scannerTranslator
	scannerTranslator(document.querySelector('#qr-reader'));
});

@manusaavedra
Copy link

const translates = [
    { en: "QR code parse error, error =", es: "Error al analizar el código QR, error =" },
    { en: "Error getting userMedia, error =", es: "Error al obtener userMedia, error =" },
    { en: "The device doesn't support navigator.mediaDevices , only supported cameraIdOrConfig in this case is deviceId parameter (string).", es: "El dispositivo no admite navigator.mediaDevices, en este caso sólo se admite cameraIdOrConfig como parámetro deviceId (cadena)." },
    { en: "Camera streaming not supported by the browser.", es: "El navegador no admite la transmisión de la cámara." },
    { en: "Unable to query supported devices, unknown error.", es: "No se puede consultar los dispositivos compatibles, error desconocido." },
    { en: "Camera access is only supported in secure context like https or localhost.", es: "El acceso a la cámara sólo es compatible en un contexto seguro como https o localhost." },
    { en: "Scanner paused", es: "Escáner en pausa" },
    { en: "Scanning", es: "Escaneando" },
    { en: "Idle", es: "Inactivo" },
    { en: "Error", es: "Error" },
    { en: "Permission", es: "Permiso" },
    { en: "No Cameras", es: "Sin cámaras" },
    { en: "Last Match:", es: "Última coincidencia:" },
    { en: "Code Scanner", es: "Escáner de código" },
    { en: "Request Camera Permissions", es: "Solicitar permisos de cámara" },
    { en: "Requesting camera permissions...", es: "Solicitando permisos de cámara..." },
    { en: "No camera found", es: "No se encontró ninguna cámara" },
    { en: "Stop Scanning", es: "Detener escáner" },
    { en: "Start Scanning", es: "Iniciar escáner" },
    { en: "Switch On Torch", es: "Encender linterna" },
    { en: "Switch Off Torch", es: "Apagar linterna" },
    { en: "Failed to turn on torch", es: "Error al encender la linterna" },
    { en: "Failed to turn off torch", es: "Error al apagar la linterna" },
    { en: "Launching Camera...", es: "Iniciando cámara..." },
    { en: "Scan an Image File", es: "Escanear un archivo de imagen" },
    { en: "Scan using camera directly", es: "Escanear usando la cámara directamente" },
    { en: "Select Camera", es: "Seleccionar cámara" },
    { en: "Choose Image", es: "Elegir imagen" },
    { en: "Choose Another", es: "Elegir otra" },
    { en: "No image choosen", es: "Ninguna imagen seleccionada" },
    { en: "Anonymous Camera", es: "Cámara anónima" },
    { en: "Or drop an image to scan", es: "O arrastra una imagen para escanear" },
    { en: "Or drop an image to scan (other files not supported)", es: "O arrastra una imagen para escanear (otros archivos no soportados)" },
    { en: "zoom", es: "zoom" },
    { en: "Loading image...", es: "Cargando imagen..." },
    { en: "Camera based scan", es: "Escaneo basado en cámara" },
    { en: "Fule based scan", es: "Escaneo basado en archivo" },
    { en: "Powered by ", es: "Desarrollado por " },
    { en: "Report issues", es: "Informar de problemas" },
    { en: "NotAllowedError: Permission denied", es: "Permiso denegado para acceder a la cámara" }
]

export class Html5QrcodeTranslate {
    #observer = null

    constructor(elementById) {
        this.#observer = new MutationObserver((mutations) => {
            mutations.forEach((mutation) => {
                if (mutation.type === 'childList') {
                    mutation.addedNodes.forEach((nodo) => {
                        this.#textNodeTranslate(nodo);
                    });
                }
            });
        })

        const config = { childList: true, subtree: true };
        this.#observer.observe(document.querySelector(elementById), config);

        this.#textNodeTranslate(document.querySelector(elementById));

        return this.#observer
    }

    disconnect() {
        this.#observer !== null && this.#observer.disconnect()
    }

    #translate(texto) {
        const translate = translates.find(t => t.en === texto);
        return translate ? translate.es : texto;
    }

    #textNodeTranslate(nodo) {
        if (nodo.nodeType === Node.TEXT_NODE) {
            nodo.textContent = this.#translate(nodo.textContent.trim());
        } else {
            for (let i = 0; i < nodo.childNodes.length; i++) {
                this.#textNodeTranslate(nodo.childNodes[i]);
            }
        }
    }
}

@Felipe-Tomazetti
Copy link

@mebjas

  • Start an effort on this with Spanish and French
  • Write a blog post describing how to do this for any language.
  • Request contributors to add support for more languages within the library

I can help with Portuguese if you need

@Felipe-Tomazetti
Copy link

Hi @mebjas, I'm mostly a backend developer, and I have just general knowledge of JS. My suggestion, or at least my point of view, is to allow everyone to easily change/update/amend the language file without the need to compile something. So, for this reason, I'd stay with some basic JS options, like a file with a single object called lang and each property as a word/phrase. I think this should allow some sort of easy attribute injection too.

lang = { hello : "ciao" }

Hi @jpaoletti, I just looked for the phrase I want to translate in the codebase, and just changed it in the code. Of course this solution doesn't work with multilanguage applications.

@croxarens How did you translated in the codebase? Directly inside node_modules?

@croxarens
Copy link
Author

@croxarens How did you translated in the codebase? Directly inside node_modules?

Correct!

@Felipe-Tomazetti
Copy link

@croxarens How did you translated in the codebase? Directly inside node_modules?

Correct!

But if you update the application with npm install or yarn add, won't it be overwritten again?

And I changed inside node_modules but nothing happened :/

@croxarens
Copy link
Author

Sorry, you are right. It was quite a long time ago. I wasn't really into JS so I didn't want to use NPM.
At that time, the library had a compiled version, which I added to my website assets and then updated the values there.
Looking at the example here, you could download the https://unpkg.com/html5-qrcode file, change what you need, and use it into your system.

And I changed inside node_modules but nothing happened :/

If you do the same inside the node_modules, I guess you'll need to recompile the code and flush the caches (browser included)

But if you update the application with npm install or yarn add, won't it be overwritten again?

Yes, I guess in that case, it would be better to add into the package.json file the version you are currently at, so that npm will not update to the next version, or just clone the project and create a local path

I hope this is helpful

@wizofaus
Copy link

wizofaus commented Nov 28, 2024

So to confirm, currently we need to modify the source code ourselves if we want to have translated messages at all? Certainly looking at the minified code it's hard to see how we could just configure it with a table of messages in a given language.
Might have to go with the MutationObserver option otherwise...

@ROBERT-MCDOWELL
Copy link

@wizofaus everything is said just above your comment

@wizofaus
Copy link

Sorry but that comment was about as clear as mud to me. Anyway I worked out my own solution using a MutationObserver.

@ROBERT-MCDOWELL
Copy link

So you can share your solution to other at least...

@wizofaus
Copy link

wizofaus commented Nov 28, 2024

It's really little different to the one posted above, just a MutationObserver on the element where the QR scanner is initialised, then check all added nodes recursively for text elements. Obviously it relies on deriving translations directly from the existing English text, but that's the system I've been using anyway (and generally works pretty well).

    translateNodeText = function (node) {
        if (node.data && node.data.trim) {
            node.data = translate(node.data.trim());
        }
        node.childNodes.forEach(translateNodeText);
    }
    translateText = function(mutationsList) {
        mutationsList.forEach(mutation => {
            mutation.addedNodes.forEach(translateNodeText)
        });
    }
    const observer = new MutationObserver(translateText);
    observer.observe($('#codescanner').get(0), { childList: true, subtree: true });

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests