por: @ElPamplina@masto.es
Las referencias a documentación en este tutorial son:
=> Obsidian - Developer Documentation | Mastodon documentation | Universal Mastodon API client for JavaScript
El código fuente del plugin está publicado bajo licencia GPLv2 en el siguiente repositorio:
=> https://github.com/elpamplina/mastodon-threading
Estando en el antiguo Twitter, empecé a hacer mis primeros hilos sobre temas diversos de mi interés (música y programación, principalmente). Entonces empecé a imaginar lo bueno que sería tener una herramienta para preparar los hilos y enviarlos directamente, sin tener que estar haciendo corta-pega.
Para programar aplicaciones para la red del pajarito azul había que abrirse una cuenta de desarrollador. Mi intento por hacerme una cuenta se quedó en nada, pues te hacían una especie de interrogatorio para evaluar si te dejaban entrar, como si aquello fuese un sancta-sanctorum. Yo, que solo quería curiosear y pasar el rato, no estaba dispuesto a eso.
Con la perspectiva del tiempo me alegro de no haberme puesto a desarrollar, porque con la deriva que siguió Twitter (luego X) poco después todo lo hecho se habría perdido.
Huyendo de Twitter, llegué a Mastodon. Su planteamiento abierto y sin restricciones, no solo es más amigable para los usuarios, sino también para los programadores. Nada de cuentas de desarrollo ni leches. Te pones a programar y accedes mediante una API REST completamente abierta. Para las operaciones que requieren autenticarse, se usa el protocolo OAuth2 para obtener un token de acceso.
Después de practicar haciendo unos cuantos bots, se me ocurrió recuperar el viejo proyecto de los hilos, pero esta vez sin el miedo de perder acceso al software cuando un millonario narcisista y acomplejado se le crucen los cables.
Un poco después de entrar en Mastodon, leí allí a alguien hablando de Obsidian. Es un software con una filosofía curiosa: partiendo de un simple editor de texto Markdown puedes montar un contenedor (la "bóveda") donde meter todo lo que se te ocurra. Puede ser un simple bloc de notas o una completa documentación para tus proyectos.
A pesar de que Obsidian no es software libre, el formato en que guarda la información sí lo es, por lo que no te quedas atrapado en ningún momento. Bajo el rimbombante nombre de bóveda no hay más que una carpeta local con archivos de texto, subcarpetas y otros archivos adjuntos. No hay nada en la nube (se puede pagar una suscripción para eso, pero no es mi caso). Si un día el software cambiase o quisiesen cobrar por él, bastaría con agarrar tus archivos y largarte.
Para hacer de Obsidian una auténtica navaja suiza, sus creadores han abierto las especificaciones para dotarlo de plugins hechos por cualquiera. De ahí me vino la idea de combinar este software que llevaba tiempo usando con mi vieja idea de publicar hilos.
Hacer una aplicación para publicar hilos en Mastodon me hubiera supuesto dedicar mucho esfuerzo para dotarla de un editor para preparar el contenido y una plataforma donde desplegarlo (ya sea en escritorio, móvil o web). A eso le añadiría el soporte multilenguaje y el manejo de imágenes con sus descripciones. Un gran esfuerzo antes de empezar ni siquiera con la API de Mastodon. ¡Pero Obsidian ya tiene todo eso! Haciendo mi proyecto como plugin para Obsidian me ahorro todo el trabajo previo y puedo ir al grano.
Hay ciertos inconvenientes:
Al primer inconveniente, he de decir que nunca me ha asustado aprender cosas nuevas en programación. La documentación del sistema de plugins de Obsidian está bastante completa, aunque no perfecta (he tenido que hacer ingeniería inversa de otros plugins ya hechos para descifrar ciertas funciones).
Al segundo me lanzo sin red, contando con que Obsidian es un proyecto bastante pujante con una comunidad muy activa y muchísimos plugins desarrollados, a los cuales no creo que vayan a abandonar de la noche a la mañana.
Obsidian proporciona un proyecto base sobre el que empezar a desarrollar un plugin. Solo tenemos que ir a la plantilla en Github y clonarla en nuestro propio proyecto.
=> https://github.com/obsidianmd/obsidian-sample-plugin
Es conveniente, aunque no obligatorio, crearnos una cuenta de Github (si no la tenemos) y clonar un repositorio a partir de esta plantilla, que nos servirá para mantener el código fuente y las versiones ejecutables en un sitio público. Esto facilitará las cosas luego cuando al final enviemos nuestro plugin para ser incluido en el repositorio oficial. El sistema de instalación de plugins oficial solo funciona sobre Github, hasta donde yo sé. Yo empecé en Codeberg (mi sitio preferido de desarrollo) pero luego tuve que recular.
El proyecto de ejemplo es bastante fácil de entender, con unos pocos conocimientos en Javascript, y contiene todos los elementos básicos:
- En el método `onload()` colocamos todo lo que debe ejecutarse en el momento de cargar Obsidian, o sea las inicializaciones.
- En `onunload()` está lo que hay que ejecutar al cerrar. Si está vacío, podemos quitarlo. Ten en cuenta que todo lo que obtengas a través de la API del sistema se libera automáticamente. Solo hay que liberar recursos que hayamos obtenido por nuestra cuenta, que no suele ser el caso.
- En la clase derivada de `PluginSettingTab` colocaremos los campos y controles de la página de ajustes del plugin. La forma de hacerlo se adivina bastante bien con el ejemplo incluido.
El resto de archivos contienen todo lo habitual en un proyecto Node.js. No es mi tarea aquí explicarte como instalar Node o preparar un entorno de desarrollo con NPM. Doy por supuesto que sabes hacerlo.
Una cosa que sí tengo que aclarar es que, aunque el entorno de desarrollo es Node, el ejecutable final no va a correr en Node, sino que el cliente Obsidian funciona en Electron, que corresponde (más o menos) con el entorno de un navegador Chrome ligeramente modificado.
Esto implica que puedes usar todo tipo de librerías Node que se puedan empaquetar (el entorno por defecto usa esbuild), pero NO llamadas nativas al core de Node.js. Esto tendrá importancia más adelante cuando hablemos de encriptar las credenciales.
Mastodon utiliza Oauth2 para autenticar, lo cual es imprescindible para poder entregar mensajes en nombre de un usuario.
La documentación de la librería Masto.js pasa de puntillas por la parte de autenticación. Muestra un ejemplo de negociación OOB (Out Of Band, fuera de banda), y nada más.
En los protocolos de autenticación, se llama fuera de banda a la transacción en la que el cliente que desea autenticarse recibe el código de autorización de manera manual (el usuario se encarga de copiarlo a mano). Es la forma menos sofisticada, y existe solo para los casos en los que no es posible automatizar la petición/respuesta (por ejemplo, si el equipo no cuenta con una interfaz y un navegador web).
Tenía claro que no quería esa autenticación, sino que el plugin reciba la autorización directamente del servidor. Para ello es necesario redirigir a una página de autenticación y, cuando el usuario ha otorgado el permiso, volver automáticamente a Obsidian con el código de respuesta. Ni en las páginas de Mastodon, ni en las de Masto.js, ni en las de Obsidian pude encontrar un ejemplo de cómo hacer eso.
Por fin, encontré unos artículos en Medium donde el autor había investigado exactamente eso:
=> OAuth in Obsidian Plugins. Late last year I finally downloaded… | by Nick Felker | Medium | OAuth in Obsidian Plugins, Part 2 | by Nick Felker | Nov, 2024 | Medium
La clave está en usar como URL de retorno una propia de Obsidian:
obsidian://nombre-del-manejador
Cuando el navegador redirija a esa URL, el sistema entregará a Obsidian su contenido, el cual actúa según los manejadores que tenga registrados.
this.registerObsidianProtocolHandler('mastodon-threading', async (data) => { if (data.action === 'mastodon-threading') { ... recibir el código en data.code ... } });
Antes de eso, habrá que lanzar el navegador invocando una URL determinada para que el usuario otorgue su permiso. Como dijimos antes, el entorno en el que se ejecuta Obsidian no es más que un Chrome modificado, por lo que la redirección es simplemente asignar la URL a window.location.href
.
Una vez sabido esto, solo tenía que saber cómo Masto.js hace para generar la URL de autenticación. Ante la ausencia de documentación, decidí husmear en el código del conocido cliente Phanpy, que usa la misma librería:
=> https://github.com/cheeaun/phanpy/blob/main/src/utils/auth.js
Para mi sorpresa, Phanpy no utiliza nada de Masto.js para autenticar, sino que prepara por sí mismo las URLs y los parámetros de llamada.
Con este ejemplo me bastó para hacer algo parecido. Lo simplifiqué evitando usar el código PKCE (opcional), para no complicar más las cosas. Es una característica opcional para dar más seguridad a operaciones delicadas que lo requieran (donde haya dineros o cosas importantes en juego). Para lo que va a hacer este plugin, ni de lejos es necesario.
En resumen, los pasos a realizar para autenticar el plugin son:
const params = new URLSearchParams({ client_name: 'Mastodon Threading for Obsidian', redirect_uris: 'obsidian://mastodon-threading', scopes: 'read write', website: 'https://github.com/elpamplina/mastodon-threading' }); const resp = await fetch( `https://${server}/api/v1/apps`, { method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'}, body: params.toString(), }, ); const resp_json = await resp.json(); return { clientId: resp_json.client_id, clientSecret: resp_json.client_secret, }
Esto genera un ID de aplicación y un código secreto que le da acceso a la parte de la API que no requiera más autorizaciones.
const params = new URLSearchParams({ client_id: clientId, scope: 'write', redirect_uri: 'obsidian://mastodon-threading', response_type: 'code', }); return `https://${server}/oauth/authorize?${params.toString()}`;
Esto genera la URL que hay que entregar al navegador y pedirá al usuario la autorización. Una característica de Oauth es que el cliente no accede en ningún momento a las credenciales del usuario, sino que la autorización se realiza en una página separada. De esta manera se garantiza que la aplicación no pueda usar la contraseña para obtener más derechos de los solicitados.
Lanzamos esta URL window.location.href
y esperamos a que, a traves del protocol handler que antes hemos registrado, nos llegue el código de autorización.
Aquí hay un pequeño problema. Al hacerse la negociación fuera de nuestra apllicación, no tenemos manera de saber cuándo va a venir el código. El usuario es un ser humano (espero) y podría tardar unos segundos, minutos, horas, o... cerrar el navegador y no hacer nada.
El autor del artículo de Medium reconoce que su solución no es muy elegante, pero (igual que yo), no sabe cómo hacerlo de otra forma. Lo que hace es establecer un intervalo de tiempo y comprobar una y otra vez si ha llegado el código.
Finalmente, me decidí a copiar su método, pero con un intervalo más largo. Él lo hace de 250 milisegundos, lo cual es excesivo, y además deja a Obsidian un poco "colgado". Yo le puse 1 segundo (más que suficiente para que el usuario cierre el navegador) y funciona mucho más fluido. Además, incorporé un botón de "Cancelar" por si el usuario ha cambiado de opinión y no quiere continuar. No es cuestión de dejar el intervalo eternamente corriendo.
let url = await getAuthURL( this.plugin.settings.server, this.plugin.settings.clientId); if (url !== null) { window.location.href = url; this.displayInterval = setInterval(() => { this.display() }, 1000);
new Setting(containerEl) .setName(t('settings.connecting', {server: this.plugin.settings.server})) .addButton((component) => { component.setButtonText(t('settings.cancel')) component.onClick(async () => { clearInterval(this.displayInterval as number); this.displayInterval = null; this.display(); }) });
Una vez tenemos el código de autorización, que es provisional, solo tenemos que usarlo para obtener un token, que es el código definitivo y permanente que usaremos ya para todas las operaciones con la API.
const params = new URLSearchParams({ client_id: clientId, client_secret: clientSecret, code: authCode, redirect_uri: 'obsidian://mastodon-threading', grant_type: 'authorization_code', scope: 'write', }); const resp = await fetch(`https://${server}/oauth/token`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: params.toString(), }); const tokenJSON = await resp.json(); if (resp.status == 200) { return tokenJSON.access_token; } else { throw Error(`getAuthToken got ${resp.status} status: ${tokenJSON.error}`) }
A partir de aquí usaremos el token en todas las interacciones con la API. Esto con la librería Masto.js se hace pasándolo a la función createRestAPIClient
function getClient(server: string, authToken: string): mastodon.rest.Client { return createRestAPIClient({ url: `https://${server}`, accessToken: authToken, }); }
En la documentación de Mastodon, se recomienda que para el client secret y el token se usen las mismas precauciones que se toman para las contraseñas. Sin embargo, no parece que sea lo más habitual. En mis revisiones del código fuente de algunos clientes, incluido Phanpy, he visto que lo normal es guardarlos en el local storage del navegador sin encriptar.
Supongo que en este tipo de aplicaciones tiene más peso la comodidad del usuario que la estricta seguridad. Sería un engorro pedir al usuario una contraseña cada vez que accedemos a una aplicación de red social.
Si encriptamos las credenciales con una clave y no queremos pedírsela al usuario todo el rato, hay que almacenar la propia clave, con lo que estamos volviendo al punto de partida. ¿Cómo guardarla de forma segura?
En principio, se me ocurrió generar una clave aleatoria, encriptar las credenciales y guardarlas por separado: una parte en el local storage y otra en el archivo de datos (settings) del plugin. Luego, durante las pruebas, me di cuenta de que eso era incompatible con tener la bóveda (el contenido de Obsidan) sincronizada entre varios dispositivos.
Los plugins y sus archivos de datos forman parte de la bóveda, pero el local storage no. Esto significaría que cada vez que el usuario quisiera usar el plugin en un dispositivo tendría que volver a autenticarse en todos ellos.
La opción de guardar juntos la clave y las credenciales encriptadas es prácticamente lo mismo que no encriptar. Finalmente, opté por una solución intermedia. Usé como clave un hash generado a partir del nombre de la bóveda, algo que es igual en todos los dispositivos que la comparten. No es muy diferente de no encriptar, pero al menos no están los datos en claro.
async loadSettings() { const key = await generateKey(this.app.vault.getName()) this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); if (this.settings.clientSecret) { this.settings.clientSecret = await decryptText(key, this.settings.clientSecret); } if (this.settings.authToken) { this.settings.authToken = await decryptText(key, this.settings.authToken); } }
Pero me quedaba otro escollo que no me esperaba. La librería de encriptación que estaba usando era nativa de Node, no empaquetable y, como dijimos antes, en el entorno de ejecución de Obsidian no hay Node disponible.
Finalmente, encontré la solución en la librería SubtleCrypto, que es una solución de bajo nivel de encriptación incluida en el motor de los navegadores. Para mi propósito, es más que suficiente y compatible con el entorno Chrome en el que corre Obsidian.
=> Documentación oficial de SubtleCrypto
Para la utilización de las funciones de encriptación, siempre complicadas, conté con un fantástico tutorial del que copié casi todas las implementaciones:
=> David Myers - A practical guide to the web cryptography API.
Por ejemplo, el hash de la clave se genera así:
const generateKey = (seed: string) => window.crypto.subtle.digest('SHA-256', unpack(seed));
La llamada window.crypto
está disponible en el navegador, sin necesidad de más librerías.
Una de mis prioridades al programar es que la interfaz sea accesible en varios idiomas. Obsidian soporta definiciones de lenguajes, pero no hay una API específica para los plugins, lo cual es una laguna importante.
Buscando ejemplos de cómo otros desarrolladores han solucionado esto, encontré un post en el propio foro de Obsidian que me dio una solución bastante buena, aunque no tengo claro si puedo considerarla una funcionalidad no documentada.
Lo primero es importar las definiciones en forma de un diccionario en JSON del estilo:
{ "seccion": { "mensaje": "Hola mundo" } }
Importamos la definición:
import * as lang_es from 'lang/es.json';
La cargamos en el sistema de lenguaje de Obsidian (i18next):
i18next.addResourceBundle('es', 'plugin-mastodon-threading', lang_es);
Usamos la función getFixedT() para obtener un mensaje en el mensaje definido por el usuario en los ajustes de Obsidian, quedando el inglés por defecto si no lo tenemos declarado:
const t = i18next.getFixedT(null, 'plugin-mastodon-threading', null);
Con este alias tan conveniente, para obtener un texto determinado, bastará con llamar a:
t('seccion.mensaje')
En un directorio lang
he creado los mensajes en inglés y en español, con la posibilidad de añadir tantos lenguajes como sea necesario en el futuro.
Una función fundamental del plugin es trocear el texto para enviarlo en varios mensajes encadenados. Será conveniente que los separadores puedan ponerse, quitarse y moverse a voluntad. Busqué un carácter o secuencia de caracteres que se puedan identificar fácilmente y que no se confundan con un texto normal.
Probé secuencias como -==-
o __**__
, pero muchos de esos caracteres tienen significado en el formato Markdown. Finalmente encontré el símbolo de párrafo (§), que pertenece al juego de caracteres ASCII básico, pero es de poco uso, ni siquiera está en los teclados normales.
Así pues, un símbolo de párrafo justo como primer carácter de una línea se interpretará como separador de hilo En cualquier otra posición no tendrá efecto. En todo caso, ante la posibilidad de cambiarlo más adelante, lo puse como constante fácilmente sustituible, y en el código no doy por hecho que tenga que ser un solo carácter:
const SEPARATOR: string = '§'
Para mostrar el separador de una forma gráfica fácilmente identificable, me planteé mostrarlo en el editor con una decoración específica: una línea horizontal con un indicador del número de trozos y la longitud en caracteres. En la siguiente captura se muestran dos fragmentos con sus separadores:
=> Captura de pantalla del editor con separadores.
Para mostrar decoración en la pantalla de edición, es necesario registrar una extensión del editor, que consiste en una clase que va a recibir una llamada cada vez que haya un evento de edición:
this.registerEditorExtension(separatorField(this));
function separatorField(plugin: MastodonThreading) { return StateField.define({ create(state): DecorationSet { return Decoration.none; }, update(oldState: DecorationSet, transaction: Transaction): DecorationSet { // ... AQUI HACER PROCESAMIENTO ... }, provide(field: StateField ): Extension { return EditorView.decorations.from(field); }, }); }
Esta clase va a manejar el decorador propiamente dicho, que es otra clase con un método toDom()
, que es el que tiene que generar el código HTML que se va a mostrar en el editor:
class SeparatorWidget extends WidgetType { count: number size: number limit: number constructor(count: number, size: number, limit: number) { super(); this.count = count; this.size = size; this.limit = limit; } toDOM(view: EditorView): HTMLElement { const div = document.createElement('div'); div.classList.add('separator'); if (this.size >= this.limit) { div.classList.add('warning'); } const counters = document.createElement('span'); counters.textContent = `${this.count} (${this.size})`; div.appendChild(counters); return div; } }
Como se ve, el separador consiste en un DIV de la clase "separator", con un SPAN que contiene la cuenta de fragmentos y del tamaño en caracteres. La cuenta se vuelve roja añadiendo la clase "warning". En el archivo styles.css
del plugin definimos cómo se visualizan las clases "separator" y "warning":
.separator { width: 100%; text-align: center; border-bottom: 1px solid #ccc; overflow: inherit; margin: 0 0 30px; font-size: 16px; color: #222222; } .separator span { background: #ccc; padding: 0 5px 0 5px; position: relative; top: 10px; font-weight: bold; } .separator.warning span { background: red; }
Todo esto junto toma el aspecto que se ha visto antes en la captura.
Una vez definido el widget, solo queda insertarlo en el lugar correspondiente en lugar del carácter §
:
builder.add(last_start_pos, last_end_pos, Decoration.replace({ widget: new SeparatorWidget( ++count, calculate_size(text), plugin.settings.maxPost) }));
El código completo del método update()
es largo para ponerlo aquí, y se puede consultar en el código fuente. Básicamente, hace un bucle leyendo el texto línea a línea y reconociendo el carácter separador al principio de línea. También se van contando los caracteres y comprobando si se supera el límite de tamaño.
Otra cosa que hace la extensión del editor es incluir un separador adicional al principio del texto, para abarcar todo el texto desde el principio hasta el primer separador real.
La forma en que lo he implementado ha buscado principalmente la eficiencia. Hay que tener en cuenta que el código de update()
se va a ejecutar cada vez que haya cualquier acción en el editor (pulsación de tecla, click de ratón, etc.) Un algoritmo demasiado pesado tendría un efecto terrible.
Me decidí a hacer la inserción de los widgets en una sola pasada sobre el texto, lo cual a la larga ha sido lo más difícil y "truquero" que he tenido que hacer en todo el proyecto.
La extensión del editor solo funciona en modo edición, así que cuando Obsidian está en modo "vista" no tiene efecto y se verían los separadores en bruto.
Para evitar esto, se usa otro tipo de extensión que son las markdown post processor. Se usa de una manera muy parecida, aunque su funcionamiento es mucho más simple:
this.registerMarkdownPostProcessor(separatorPostProcessor);
const separatorPostProcessor: MarkdownPostProcessor = (element, context) => { let text = element.innerHTML; if (text !== null) { text = text.replace(new RegExp(SEPARATOR, "g"), '
'); element.innerHTML = text; } }
Como se ve, simplemente se sustituye por una línea horizontal HR, sin contadores ni otros adornos.
Para la operación del plugin, añadimos comandos a la paleta de Obsidian, y opcionalmente también menús contextuales e iconos de la cinta de herramientas. Su funcionamiento es bastante simple: obtenemos acceso a un objeto Editor
, a través del cual accedemos al texto del editor, el rango seleccionado (si lo hay), y funciones para modificar lo que sea necesario.
this.addCommand({ id: 'send-thread', name: t('command.send_thread'), icon: 'mastodon', editorCallback: (editor: Editor, view: MarkdownView) => { this.thread_post(editor); } });
this.addRibbonIcon('mastodon', t('command.send_thread'), () => { const editor = this.app.workspace.activeEditor?.editor; if (editor) { this.thread_post(editor); } });
Llamando al mismo método desde el comando y desde la cinta de iconos obtengo la misma funcionalidad. En el primer caso, el editor lo recibo como llamada a un callback, mientras que en el segundo tengo que buscarlo dentro del objeto workspace.
Para usar un icono personalizado, hay que declararlo en formato SVG, para lo cual conté con la inestimable ayuda de Ulises Lafuente (@Rataunderground@paquita.masto.host).
El acceso al editor es bastante intuitivo. Por ejemplo, para insertar un separador en la posición del cursor, hacemos lo siguiente:
insert_separator(editor: Editor) { editor.replaceRange( editor.getCursor().ch === 0 ? SEPARATOR : `\n${SEPARATOR}`, editor.getCursor() ); }
Las posiciones del cursor se establecen por línea (equivalente en Markdown a un párrafo) y carácter dentro de la línea. En el caso de que el carácter donde se encuentra el cursor sea el 0 (inicio del párrafo), inserto solo el separador, o en caso contrario, lo uno a un salto de línea. De esta manera, el separador siempre queda al inicio de un párrafo.
Usando el token que hemos obtenido al autenticar, se puede obtener un cliente de la API encapsulado por Masto.js:
function getClient(server: string, authToken: string): mastodon.rest.Client { return createRestAPIClient({ url: `https://${server}`, accessToken: authToken, }); }
Ese cliente tiene todas las llamadas a la API tal y como aparecen en la documentación de Mastodon. Para enviar un post:
this.getClient().v1.statuses.create({status: message, visibility: visibility}) .then(status => { new Notice(t('ok.message_posted')); }) .catch(err => { console.error(err); new Notice(t('error.not_posted')); });
Como es habitual en Javascript, todas las llamadas a la red son asíncronas y hay que manejarlas con un await o una promesa.
La clase Notice es la que usa Obsidian para mostrar un mensaje al usuario en forma de toast o cuadro de mensaje efímero.
Para hacer hilos es bastante interesante poder adjuntar imágenes al texto. La forma en que Obsidian adjunta imágenes dentro del editor no es la estándar de Markdown, sino que usa su propia sintaxis:
![[ruta.a.la.imagen|tamaño-opcional]]
Tuve que hacerme una expresión regular bastante compleja para extraer la ruta del archivo de imagen, que es lo que necesito para postearlo.
Además, como se ve, no hay nada previsto como texto descriptivo de la imagen, así que tuve que inventarme mi propia forma de hacerlo. Se me ocurrió usar los bloques de citas (quotes), que en Markdown se marcan con el signo mayor que (>
), para esa misión.
![[ruta.a.la.imagen|tamaño-opcional]] > Esto es la descripción
Concretamente, una cita justo después de una imagen se interpretará como el texto descriptivo. Así, extendí mi expresión regular para recoger también ese texto.
Pero eso me generó un problema para procesar otros bloques de citas que no estén unidos a imágenes. La cosa complicó enormemente el procedimiento update()
de la extensión del editor, precisamente el que necesitaba que fuese eficiente y sencillo.
La solución fue ignorar todos los bloques de citas sueltos, que pasan a ser una especie de comentario sin efecto en el hilo. De una necesidad, surgió una curiosa funcionalidad extra.
@ElPamplina@masto.es
elpamplinadecai@gmail.com
=> Volver al índice This content has been proxied by September (ba2dc).Proxy Information
text/gemini