Compare commits
7 commits
PRUEBAS
...
0.8.7_UX_P
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9eb5123c03 | ||
|
|
8aaba6585a | ||
|
|
6c816bab94 | ||
|
|
c7419a44d5 | ||
|
|
bc0f55e914 | ||
|
|
9c100ed773 | ||
|
|
851042e576 |
|
|
@ -1,68 +0,0 @@
|
|||
# Construye la APK y la publica en las releases.
|
||||
#
|
||||
# Con un tag v* se publica directamente: crear el tag ya es la decision de sacar
|
||||
# version, y es la que enlaza el README (releases/latest). Lanzada a mano desde
|
||||
# la interfaz se queda en borrador, para revisarla antes.
|
||||
#
|
||||
# NO se dispara sola con cada commit: la APK son mas de 100 MB y el backend tarda
|
||||
# en empaquetarse. Se lanza a mano desde la interfaz de gitea, o al crear un tag.
|
||||
#
|
||||
# El runner necesita: JDK 17 con compilador, el SDK de Android (plataforma 35 y
|
||||
# build-tools), node, zip y unzip. Un runner pelado NO vale.
|
||||
#
|
||||
# Secretos que hay que dar de alta en el repositorio:
|
||||
# OASIS_KEYSTORE_B64 el almacen de claves en base64
|
||||
# OASIS_KEYSTORE_PASS su contraseña
|
||||
# OASIS_KEY_ALIAS el alias (por defecto "oasis")
|
||||
#
|
||||
# El token para publicar lo pone gitea solo en GITEA_TOKEN.
|
||||
|
||||
name: APK
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0 # release.sh cuenta commits para el versionCode
|
||||
|
||||
- name: Traer el APK oficial de la misma version (node_modules y libnode.so)
|
||||
run: |
|
||||
v=$(node -p "require('./src/server/package.json').version")
|
||||
curl -sfL -o /tmp/oasis-base.apk \
|
||||
"https://github.com/epsylon/oasis/releases/download/oasis-android-v$v/oasis-v$v.apk"
|
||||
echo "OASIS_APK_BASE=/tmp/oasis-base.apk" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Restaurar el almacen de claves
|
||||
run: |
|
||||
echo "${{ secrets.OASIS_KEYSTORE_B64 }}" | base64 -d > /tmp/oasis.jks
|
||||
chmod 600 /tmp/oasis.jks
|
||||
|
||||
- name: Construir, verificar y publicar
|
||||
env:
|
||||
JAVA_HOME: /usr/lib/jvm/temurin-17-jdk-amd64
|
||||
ANDROID_HOME: /opt/android-sdk
|
||||
OASIS_KEYSTORE: /tmp/oasis.jks
|
||||
OASIS_KEYSTORE_PASS: ${{ secrets.OASIS_KEYSTORE_PASS }}
|
||||
OASIS_KEY_ALIAS: ${{ secrets.OASIS_KEY_ALIAS }}
|
||||
OASIS_GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
OASIS_GITEA_URL: ${{ github.server_url }}
|
||||
OASIS_GITEA_REPO: ${{ github.repository }}
|
||||
run: |
|
||||
cd android
|
||||
if [ "${{ github.ref_type }}" = "tag" ]; then
|
||||
./scripts/release.sh --release --publish --public
|
||||
else
|
||||
./scripts/release.sh --release --publish
|
||||
fi
|
||||
|
||||
- name: Borrar el almacen de claves
|
||||
if: always()
|
||||
run: rm -f /tmp/oasis.jks /tmp/oasis-base.apk
|
||||
5
.gitignore
vendored
|
|
@ -1,6 +1 @@
|
|||
node_modules/
|
||||
|
||||
# pruebas en local: el node_modules suele ser un enlace y el estado de la llamada lo escribe el movil
|
||||
src/server/node_modules
|
||||
.phone_state.json
|
||||
android/.tools/
|
||||
|
|
|
|||
|
|
@ -1,228 +0,0 @@
|
|||
# Guía de desarrollo
|
||||
|
||||
Notas para quien vaya a meter mano en la rama del móvil. Leedlas antes de empezar,
|
||||
que nos ahorramos disgustos.
|
||||
|
||||
Esta rama va encima de Oasis, que es de epsylon. Él saca versión casi cada día
|
||||
(39 entre la 1.0.1 y la 1.3.3, en siete semanas) y cada versión llega en un solo
|
||||
commit. Si metemos cosas en sus ficheros, en cada integración hay que volver a
|
||||
pelearse con ellas. Por eso la idea es sencilla: lo de Oasis lo cambia él; nosotros
|
||||
cambiamos cómo se ve y se usa en el móvil, y lo hacemos en ficheros nuestros.
|
||||
|
||||
Los números y de dónde salen están en `docs/FRONTERA_UPSTREAM.md`.
|
||||
|
||||
|
||||
## Lo que no se toca
|
||||
|
||||
- `src/backend/backend.js`. Cambia en 34 de cada 39 versiones. Ni rutas nuevas, ni
|
||||
middleware, ni envolver funciones suyas como `checkMod`.
|
||||
- La cadena de middleware del final de `backend.js`. Crece en cada versión.
|
||||
- Los modelos de `src/models/`, salvo el nuestro (`fork_workflows.js`). Ahí se decide qué se publica en la red y cómo se cuenta.
|
||||
Si lo cambiamos, nuestro móvil cuenta distinto que el escritorio de al lado. Un
|
||||
ejemplo real: quien crea una votación no puede votar en ella. No nos gusta, pero
|
||||
es una regla del modelo, así que se explica en pantalla y punto.
|
||||
- `src/server/` (arranque de SSB, `ssb_config.js`, `package.json`).
|
||||
- `src/backend/request_guards.js`, que lleva la CSP. Nada de `style=""` ni de
|
||||
`<script>` en la página.
|
||||
- Las traducciones del dev, `translations/oasis_*.js`. Se reescriben casi en cada
|
||||
versión.
|
||||
- `styles/style.css` y `styles/mobile.css`.
|
||||
- Los temas del dev: Dark, Clear, Purple, Matrix y también `OasisMobile.css`, que
|
||||
aunque parezca nuestro es suyo (lo toca en 21 de 39 versiones).
|
||||
|
||||
|
||||
## Las vistas del dev: una línea y ya
|
||||
|
||||
`src/views/*_view.js` y `main_views.js` son del dev y las reescribe a menudo
|
||||
(`tribes_view.js` en 23 versiones, `main_views.js` en 31). Si en una pantalla suya
|
||||
hace falta otro HTML en el móvil, el código va en `src/views/mobile_ui.js` y en la
|
||||
vista solo queda la llamada:
|
||||
|
||||
```js
|
||||
const mobileUi = require('./mobile_ui');
|
||||
// ...
|
||||
if (mobileUi.isMobile()) return mobileUi.tribeTabs(tribe, section, sections);
|
||||
```
|
||||
|
||||
Cuando el dev reescribe la vista, se vuelve a poner esa línea y listo. En escritorio
|
||||
no cambia nada porque la bandera `OASIS_MOBILE` solo la pone `main.js` en el móvil.
|
||||
|
||||
Ahora mismo hay enganches en `main_views.js`, `tribes_view.js`, `vote_view.js`,
|
||||
`settings_view.js` y `welcome_view.js`. Para verlos: `grep -n mobileUi src/views/*.js`.
|
||||
|
||||
|
||||
## Llamadas: las del dev
|
||||
|
||||
La rama tuvo un módulo de salas y llamadas propio (Karvan). Se quitó el 19/10/2026
|
||||
porque el dev ya trae Teléfono y Salas. En Android el sonido y la cámara pasan por un
|
||||
puente nativo de la app del dev (`.audio_bridge`; los ficheros
|
||||
`src/server/phone_*_android.js` también son suyos). Por eso la APK se monta sobre la
|
||||
oficial con `android/scripts/sobre_oficial.sh`: su app tal cual, nuestro backend
|
||||
dentro y paquete propio. Si falta algo en las llamadas, se le pide al dev, no se monta
|
||||
otro módulo.
|
||||
|
||||
Ojo al comparar con el dev: para el móvil la referencia es el `nodejs-project` de su
|
||||
APK oficial, no su repositorio git. Hay ficheros, como `SSB_server.js` o
|
||||
`phone_module.js`, cuya versión de móvil solo está en la APK.
|
||||
|
||||
Lo mismo vale para cualquier módulo: antes de hacer uno propio, mirad si el dev ya lo
|
||||
tiene o lo está haciendo.
|
||||
|
||||
|
||||
## Navegación
|
||||
|
||||
Al abrir se ve la actividad. Los módulos están en la quinta pestaña de abajo, Menú,
|
||||
que abre una hoja con el lanzador (`hive_views.js`: una casilla y CSS). Un script
|
||||
mínimo, `public/js/mobile_menu.js`, hace que el botón atrás de Android cierre la hoja;
|
||||
sin él el menú funciona igual. La barra tiene siempre cuatro atajos elegidos por cada cual más Menú. Por qué
|
||||
así: `docs/ESTUDIO_NAVEGACION.md`. Si se cambia, que sea con una prueba que lo
|
||||
justifique, no por gusto.
|
||||
|
||||
|
||||
## Lo que es nuestro
|
||||
|
||||
Aquí se puede hacer lo que haga falta.
|
||||
|
||||
- `src/client/assets/styles/mobile_base.css`: la maqueta del móvil (lanzador, barras,
|
||||
tarjetas, formularios). Se carga siempre en el móvil, antes del skin.
|
||||
- `src/client/assets/styles/mobile_ux.css`: márgenes, letra, botones y todas las
|
||||
piezas nuevas. Vale para los dos skins de móvil.
|
||||
- `src/client/assets/themes/Acampada.css` y `styles/hive_acampada.css`: el skin
|
||||
Acampada.
|
||||
- `src/views/mobile_ui.js`: lo que se enchufa en vistas del dev.
|
||||
- `src/views/mobile_modules_view.js`, `identities_view.js`: pantallas propias.
|
||||
- `src/views/hive_views.js`: lanzador y barras de arriba y abajo.
|
||||
- `src/views/mobile_icons.js`: los iconos.
|
||||
- `src/backend/fork_routes.js`: nuestro router. Va justo delante del del dev.
|
||||
- `src/backend/mobile_layer.js`: lo que nuestra interfaz necesita del backend
|
||||
(avatar, filtro y lanzador de la barra de arriba). La monta `fork_routes.js`.
|
||||
- `src/models/fork_workflows.js`: nuestros workflows.
|
||||
- `src/client/assets/translations/i18n_fork.js`: nuestros textos.
|
||||
- `src/client/assets/images/ux-*.svg`: nuestras imágenes.
|
||||
- `android/scripts/sobre_oficial.sh`: monta la APK sobre la oficial del dev. El resto
|
||||
de `android/` (el proyecto Gradle propio) queda como estaba, sin uso.
|
||||
|
||||
|
||||
## Cómo hacer cada cosa
|
||||
|
||||
Cambiar cómo se ve algo. Se busca la clase en el HTML (servidor local e inspector) y
|
||||
se escribe la regla en `mobile_ux.css`. Para los colores usad las variables `--ux-*`
|
||||
(`--ux-bg`, `--ux-fg`, `--ux-accent`, `--ux-muted`, `--ux-line`, `--ux-danger`), que
|
||||
tiran de Acampada si está y si no de la paleta de OasisMobile. Si la regla no gana,
|
||||
es que `mobile.css` o `Acampada.css` mandan con `!important` y selectores largos:
|
||||
hay que subir la especificidad y poner `!important` también. Ojo con una trampa:
|
||||
`Acampada.css` quita el borde a todos los `div`, así que para ponérselo a uno hace
|
||||
falta `.main-column div.la-clase`.
|
||||
|
||||
Letra: Anton para títulos y para el botón negro principal de cada pantalla
|
||||
(`.create-button`). Lo demás, letra del sistema en negrita.
|
||||
|
||||
Márgenes: 14 px a cada lado y ya. Los contenedores de en medio sin relleno, que
|
||||
antes se iban sumando y una ficha perdía 74 px por lado.
|
||||
|
||||
Cambiar el HTML de una pantalla del dev. Función en `mobile_ui.js` y una línea en la
|
||||
vista, como arriba.
|
||||
|
||||
Una pantalla o una ruta nueva. En `fork_routes.js`. Lo que no atiende nuestro
|
||||
router pasa al del dev con `next()`:
|
||||
|
||||
```js
|
||||
.get('/modules', async (ctx, next) => {
|
||||
if (!isMobile()) return next(); // escritorio: la del dev
|
||||
ctx.body = mobileModulesView({ ... });
|
||||
})
|
||||
```
|
||||
|
||||
Retocar lo que contesta el dev. También en `fork_routes.js`, en `wrapUpstream`: deja
|
||||
que el dev conteste y luego mira la respuesta. Así sabemos a qué sección apagada se
|
||||
quería entrar (el dev solo manda a `/modules`) o añadimos `?saved=1` al guardar.
|
||||
|
||||
Textos. En `i18n_fork.js` y en los 11 idiomas (en, es, fr, eu, de, it, pt, zh, ar,
|
||||
hi, ru). Antes, mirad que la clave no exista ya en `oasis_en.js`, porque si existe la
|
||||
estaríais cambiando también en escritorio. En el código siempre con texto de reserva:
|
||||
`i18n.miClave || 'My text'`.
|
||||
|
||||
Un workflow. En `fork_workflows.js`, que lo mete en las listas que exporta el modelo
|
||||
del dev. No se toca `workflows_model.js`.
|
||||
|
||||
Un icono. En `mobile_icons.js`, trazo de 2 px sobre rejilla de 24 y `currentColor`. El
|
||||
nombre es el de la ruta: `/tribes` es `tribes`.
|
||||
|
||||
|
||||
## Antes de hacer commit
|
||||
|
||||
- Que `git diff --stat` no toque nada de la lista de lo que no se toca. En las vistas
|
||||
del dev, solo líneas de enganche.
|
||||
- `node --check` en los `.js` que hayáis cambiado.
|
||||
- Arrancar en local y ver que `/activity`, `/tribes`, `/modules`, `/settings`,
|
||||
`/votes`, `/phone`, `/rooms` e `/inbox` cargan.
|
||||
- Mirarlo a 412 px de ancho, y a 360 si es algo ancho.
|
||||
- Si es CSS nuevo, mirarlo también en un WebView viejo (Android 9 trae el 69): no
|
||||
hay `:has`, ni `gap` en flex, ni `inset`, ni `dvh`. Lo que dependa de eso va con
|
||||
alternativa; hay un bloque `@supports not (inset: 0)` al final de `mobile_ux.css`.
|
||||
En emulador x86 la APK no arranca por debajo de Android 12 (el traductor ARM no
|
||||
sabe ejecutar el node del dev), así que el viejo se prueba con el servidor local y
|
||||
el navegador de pruebas del WebView.
|
||||
- Los textos nuevos, en los 11 idiomas.
|
||||
- Nada en línea, que la CSP lo bloquea.
|
||||
- Commit en castellano, sin coautorías de herramientas.
|
||||
- Sin claves, contraseñas ni nada de `.ssb`.
|
||||
|
||||
|
||||
## Cuando sale versión nueva del dev
|
||||
|
||||
1. Lo suyo se coge tal cual, y para los ficheros con versión de móvil, la de su APK
|
||||
oficial (`assets/nodejs-project.zip`), no la de su git.
|
||||
2. En sus vistas se vuelven a poner los enganches. El `grep -n mobileUi` sobre la
|
||||
versión anterior de la rama dice dónde iban.
|
||||
3. Se repite la medición de `docs/FRONTERA_UPSTREAM.md`: qué ha tocado y si ha
|
||||
desaparecido alguna clase que usen nuestras hojas.
|
||||
4. Se repasan a 412 px las pantallas de la lista de arriba.
|
||||
5. Se monta la APK sobre la oficial de esa misma versión:
|
||||
`OASIS_VERSION=x.y.z android/scripts/sobre_oficial.sh`.
|
||||
|
||||
|
||||
## Probar en local
|
||||
|
||||
```sh
|
||||
HOME=/tmp/oasis-prueba OASIS_NETWORK_PAUSED=1 node main.js
|
||||
```
|
||||
|
||||
Y en el navegador `http://localhost:3000` con el inspector en modo móvil a 412x915.
|
||||
Va con una identidad de usar y tirar y con la red parada.
|
||||
|
||||
Cosas que pasan:
|
||||
- `src/server/node_modules` no está en git. Sirve el de la APK base.
|
||||
- Si al reiniciar sale "Another Oasis instance is already running", el anterior sigue
|
||||
vivo y estáis viendo código viejo. Matadlo primero.
|
||||
- Al arrancar desaparecen `src/configs/banking-eco-history.json` y
|
||||
`fediverse-accounts.json`. Antes del commit, `git checkout -- src/configs/`.
|
||||
|
||||
En el emulador (`Pixel_9_API_36` de Android Studio), la APK de depuración deja
|
||||
inspeccionar la WebView desde `chrome://inspect`.
|
||||
|
||||
|
||||
## Lo que todavía está mal
|
||||
|
||||
Para el móvil, la referencia es el `nodejs-project` de la APK oficial del dev, no su
|
||||
git: él mete cambios de móvil (en `backend.js`, `SSB_server.js`, `phone_module.js`...)
|
||||
que solo están en la APK. Comparado con eso, la rama cambia 17 ficheros suyos, 561
|
||||
líneas (19/10/2026). Por tamaño:
|
||||
|
||||
- `backend/blobHandler.js` (190): limpiar los metadatos de las fotos también en
|
||||
Android. Es una función nuestra metida en su fichero; habría que proponérsela al dev.
|
||||
- `configs/oasis-config.json` (87) y `configs/config-manager.js` (18): la config de
|
||||
serie del móvil (tema Acampada, workflow acampada, barra de abajo).
|
||||
- `views/main_views.js` (62), `settings_view.js` (34), `welcome_view.js` (11),
|
||||
`tribes_view.js` (9), `vote_view.js` (7): enganches a `mobile_ui.js` y el orden de
|
||||
carga de las hojas. Es lo que la guía permite, pero hay que revisarlo en cada versión.
|
||||
- `main.js` (45): DNS del sistema primero, para respetar la VPN.
|
||||
- `server/ssb_config.js` (30): varias identidades en el mismo móvil.
|
||||
- `models/workflows_model.js` (17): la lógica de skins del móvil (`themeOf`,
|
||||
`sameTheme`). `settings_view.js` coge `currentWorkflow` al cargar y no se puede
|
||||
envolver desde fuera.
|
||||
- `styles/mobile.css` (14), `bookmark_view.js` (13), `forum_view.js` (7),
|
||||
`translations/i18n.js` (5, la mezcla con `i18n_fork.js`): arreglos sueltos de antes.
|
||||
- `backend/backend.js` (2): la línea que monta nuestro router.
|
||||
|
||||
Cuando el dev saque versión, se repite esta comparación con su APK nueva.
|
||||
472
README.md
|
|
@ -1,256 +1,328 @@
|
|||
# OASIS_MOBILE — rama de desarrollo experimental
|
||||
|
||||
> ## ⚠ ESTO NO ES OASIS OFICIAL
|
||||
>
|
||||
> Esta es una **rama de desarrollo experimental** mantenida por el hacklab, **no
|
||||
> afiliada** al proyecto original.
|
||||
>
|
||||
> ### El repositorio oficial de Oasis es el de epsylon: https://github.com/epsylon/oasis
|
||||
>
|
||||
> Si quieres **usar Oasis de verdad, instala el oficial**. Lo que hay aquí puede
|
||||
> romperse, cambiar de forma incompatible o desaparecer sin aviso. No está auditado y
|
||||
> no tiene garantía de ninguna clase.
|
||||
>
|
||||
> **Los fallos de esta rama se reportan aquí, nunca a epsylon.**
|
||||
|
||||
Si vas a desarrollar, lee antes la [guía de desarrollo](GUIA_DE_DESARROLLO.md). Ahí
|
||||
está qué se puede tocar y qué no. En corto: el backend, el middleware, los modelos y
|
||||
las traducciones son de epsylon y no se tocan; lo nuestro va en hojas, vistas y rutas
|
||||
propias.
|
||||
|
||||
Versión base: Oasis 1.3.3 · Rama: `PRUEBAS` ·
|
||||
[Documentación completa](https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki) ·
|
||||
[Versión de escritorio](https://gitea.laenre.net/hacklab/OASIS_LINUX)
|
||||
|
||||

|
||||
# Oasis
|
||||
|
||||
Oasis is a **libre, open-source, encrypted, peer-to-peer, distributed (not decentralized!) & federated**... project networking application
|
||||
that helps you follow interesting content and discover new ones.
|
||||
|
||||

|
||||
|
||||
Oasis redefines what it means to be connected in the modern world, giving people
|
||||
the ability to control their online presence and interactions without the need for centralized institutions.
|
||||
|
||||
----------
|
||||
|
||||
## Frontend:
|
||||
|
||||
Main features of the Oasis interface are:
|
||||
|
||||
+ Data manipulation is not permitted due to the use of BLOCKCHAIN technology.
|
||||
+ No browser JavaScript. Just pure HTML+CSS. A really secure frontend!.
|
||||
+ Use your favorite web browser to read and write messages to the people you care about.
|
||||
+ Strong cryptography in every single point of the network.
|
||||
+ You are the center of your own distributed network. Online or offline, it works anywhere that you are.
|
||||
+ Initial identities are randomnly generated (no username or password required).
|
||||
+ No personal profile generated (no questions about gender, age, location, etc …).
|
||||
+ Automatic exif stripping (such as GPS coordinates) on images for better privacy.
|
||||
+ No email or associated mobile phone required.
|
||||
+ Automatic updates with new functionalities.
|
||||
|
||||

|
||||
|
||||
But it has others features that are also really interesting, for example:
|
||||
|
||||
+ Support for multiple languages.
|
||||
|
||||

|
||||
|
||||
+ Modularity to set your own environment.
|
||||
|
||||

|
||||
|
||||
+ Support for multiple themes (including Mobile Theme).
|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
And some other nice modules, like for example:
|
||||
|
||||
+ A complex Reddit-styled forum system.
|
||||
|
||||

|
||||
|
||||
+ Or a collaborative -offline- maps system.
|
||||
|
||||

|
||||
|
||||
And much more, that we invite you to discover by yourself ;-)
|
||||
|
||||

|
||||
|
||||
----------
|
||||
|
||||
## Modules:
|
||||
|
||||
Oasis is TRULY MODULAR. Here's a list of what comes deployed with the "core".
|
||||
|
||||
+ Agenda: Module to manage all your assigned items.
|
||||
+ AI: Module to talk with a LLM called '42'.
|
||||
+ AINav: Module for natural-language queries about the network's content.
|
||||
+ Audios: Module to discover and manage audios.
|
||||
+ Banking: Module to determine the real value of ECOIN and distribute a UBI using the common treasury.
|
||||
+ BlockExplorer: Module to navigate the blockchain.
|
||||
+ Bookmarks: Module to discover and manage bookmarks.
|
||||
+ Calendars: Module to discover and manage calendars.
|
||||
+ Chats: Module to discover and manage encrypted chats.
|
||||
+ Cipher: Module to encrypt and decrypt your text symmetrically (using a shared password).
|
||||
+ Courts: Module to resolve conflicts and emit veredicts.
|
||||
+ Documents: Module to discover and manage documents.
|
||||
+ Events: Module to discover and manage events.
|
||||
+ Favorites: Module to manage your favorite content.
|
||||
+ Fediverse: Manage your other fediverse accounts, including sending and receiving content.
|
||||
+ Feed: Module to discover and share short-texts (feeds).
|
||||
+ Forums: Module to discover and manage forums.
|
||||
+ Games: Module to play and share your scores in various mini-games.
|
||||
+ Governance: Module to discover and manage votes.
|
||||
+ Graphos: Module to explore the network as an interactive map of peers.
|
||||
+ Images: Module to discover and manage images.
|
||||
+ Invites: Module to manage and apply invite codes.
|
||||
+ Jobs: Module to discover and manage jobs.
|
||||
+ Legacy: Module to manage your secret (private key) quickly and securely.
|
||||
+ Latest: Module to receive the most recent posts and discussions.
|
||||
+ L.A.R.P.: Module for a live-action role-playing layer with 9 houses.
|
||||
+ Logs: Module to record (via AI assistant) your experiences.
|
||||
+ Maps: Module to manage and share offline maps.
|
||||
+ Market: Module to exchange goods or services.
|
||||
+ Melody: Module to generate and share the "sound" of your blockchain.
|
||||
+ Multiverse: Module to receive content from other federated peers.
|
||||
+ Opinions: Module to discover and vote on opinions.
|
||||
+ Pads: Module to manage collaborative encrypted text editors.
|
||||
+ Parliament: Module to elect governments and vote on laws.
|
||||
+ Pixelia: Module to draw on a collaborative grid.
|
||||
+ Projects: Module to explore, crowd-funding and manage projects.
|
||||
+ Popular: Module to receive posts that are trending, most viewed, or most commented on.
|
||||
+ Reports: Module to manage and track reports related to issues, bugs, abuses, and content warnings.
|
||||
+ Shops: Module to manage and discover shops.
|
||||
+ Summaries: Module to receive summaries of long discussions or posts.
|
||||
+ Tags: Module to discover and explore taxonomy patterns (tags).
|
||||
+ Tasks: Module to discover and manage tasks.
|
||||
+ Threads: Module to receive conversations grouped by topic or question.
|
||||
+ Topics: Module to receive discussion categories based on shared interests.
|
||||
+ Torrents: Module to explore and manage torrents.
|
||||
+ Transfers: Module to discover and manage smart-contracts (transfers).
|
||||
+ Trending: Module to explore the most popular content.
|
||||
+ Tribes: Module to explore or create tribes (groups).
|
||||
+ Videos: Module to discover and manage videos.
|
||||
+ Wallet: Module to manage your digital assets (ECOin).
|
||||
|
||||
[⬇ Descargar la APK](https://gitea.laenre.net/hacklab/OASIS_MOBILE/releases) ·
|
||||
Android 7 o superior, 64 bits · se instala junto al Oasis oficial sin pisarlo
|
||||
Both the codebase and the inhabitants can generate new modules.
|
||||
|
||||
### Novedades de esta versión
|
||||
----------
|
||||
|
||||
- Al día con Oasis 1.3.3: karma en el perfil, salas con directo, fotos selladas en
|
||||
chats privados y tribus, visibilidad de menciones y las traducciones nuevas.
|
||||
- Arranca en modo acampada: chats, teléfono, salas, pads, foro, wiki, tribus,
|
||||
parlamento, votaciones y eventos. El resto se enciende en *Módulos* o en
|
||||
*Ajustes → Workflows*.
|
||||
- Las llamadas son las de Oasis. Fuera Karvan, el módulo propio de salas: ahora se usan
|
||||
el Teléfono y las Salas del dev, con su levantar la mano, grabación y emisión.
|
||||
- La APK se monta sobre la oficial: se coge la del dev, se le cambia el backend por el
|
||||
de la rama y se le da otro nombre de paquete. Así trae todo lo nativo del dev (el
|
||||
puente de audio y cámara, las notificaciones de llamada) y convive con la oficial.
|
||||
- Al abrir se ve la actividad, no un menú. Los módulos están en la quinta pestaña de
|
||||
abajo, Menú, que abre una hoja desde abajo en cualquier pantalla.
|
||||
- Votaciones como en asamblea: al crear una se elige Sí/No, semáforo (verde, amarillo,
|
||||
rojo) o termómetro (del 1 al 5). Los resultados salen en barras.
|
||||
- Borrar pide confirmación. Antes la X de una tribu la borraba de un toque.
|
||||
- Iconos que se entienden en el lanzador y en la barra, en vez de letras del alfabeto
|
||||
vai. La barra marca dónde estás y se esconde al escribir.
|
||||
- Tribus en filas: doce comisiones caben en tres pantallas, no en diez. Dentro de una
|
||||
tribu, las secciones van en una tira de fichas.
|
||||
- Módulos con interruptores, agrupados como en el lanzador. Si entras en una sección
|
||||
apagada, te lo dice y la enciendes de un toque.
|
||||
- Pantallas más aprovechadas: un margen de 14 px en vez de los 74 que se sumaban, una
|
||||
sola letra para botones, buscador en una línea y el buzón en pocas líneas por mensaje.
|
||||
- Fotos más seguras: si una foto no se puede limpiar de metadatos (ubicación,
|
||||
cámara…), no se sube y se avisa.
|
||||
- DNS: primero el resolutor del sistema, que respeta la VPN y el DNS privado de
|
||||
Android; los públicos quedan de respaldo.
|
||||
|
||||
---
|
||||
|
||||
## Lo que añade
|
||||
|
||||
| | Oasis oficial | Esta rama |
|
||||
|---|---|---|
|
||||
| Llamadas y salas | Teléfono y Salas, con micrófono y cámara | Los mismos, tal cual |
|
||||
| Interfaz de móvil | El menú de escritorio en una pantalla pequeña | Tema Acampada, lanzador con iconos, fichas, barra de cuatro atajos |
|
||||
| Primer arranque | Casi sesenta módulos encendidos | Modo acampada: lo justo para organizarse |
|
||||
| Votaciones | Seis opciones fijas en una tabla | Sí/No, semáforo o termómetro, con barras |
|
||||
| Varias identidades | Una por instalación | Varias en el mismo móvil, con aviso si dos comparten clave |
|
||||
| App Android | La del dev | La del dev, con el backend de la rama y paquete propio (`net.laenre.oasis`) |
|
||||
| Metadatos de las fotos | Se quitan solo si está `sharp`; en Android nunca | Se quitan siempre: GPS, marca, modelo, número de serie |
|
||||
## C-AI (collective artificial intelligence)
|
||||
|
||||
Los nombres de las categorías, los módulos y el resto de la aplicación son los de
|
||||
epsylon, sin tocar. Con el tema OasisMobile se ve el del propio dev.
|
||||
|
||||
---
|
||||
|
||||
## Qué es Oasis, en cuatro dibujos
|
||||
|
||||
Una red social sin servidor: publicas en tu propio equipo y los demás se traen una
|
||||
copia. No hay cuenta, no hay contraseña y nadie puede borrarte.
|
||||
Oasis contains its own AI model called "42".
|
||||
|
||||

|
||||
The main idea behind this implementation is to enable distributed learning generated through the collective action of many individuals, with the goal of redistributing the necessary processing load, as well as the ecological footprint and corporate bias.
|
||||
|
||||
Los mensajes viajan de vecino en vecino, así que llegan a gente con la que nunca te
|
||||
has conectado. Por eso funciona incluso sin internet, entre dos móviles en la misma wifi.
|
||||

|
||||
|
||||

|
||||
Our AI is trained with content from the OASIS network and its purpose is to take action and obtain answers to individual, but also global, problems.
|
||||
|
||||
Un pub es solo un nodo que no se apaga nunca. No es un servidor con tus datos: no
|
||||
guarda tu identidad, no decide qué ves y no puede borrarte.
|
||||
+ https://wiki.solarnethub.com/socialnet/ai
|
||||
|
||||

|
||||
----------
|
||||
|
||||
Todo lo que publicas se encadena y va firmado con tu clave. Nadie puede colar un
|
||||
mensaje en tu nombre ni cambiar lo que dijiste.
|
||||
## Parliament
|
||||
|
||||

|
||||
Oasis contains its own Parliament (Government system).
|
||||
|
||||
> La explicación completa —los saltos, las tribus, el LARP, los módulos— está en
|
||||
> [Entender Oasis en diez minutos](https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki/Entender).
|
||||

|
||||
|
||||
## Courts
|
||||
|
||||
---
|
||||
Oasis contains its own Courts (Justice system).
|
||||
|
||||
## Llamadas y salas
|
||||

|
||||
|
||||
## ECOin
|
||||
|
||||

|
||||
Oasis contains its own cryptocurrency. With it, you can exchange items and services in the marketplace.
|
||||
|
||||
El Teléfono (llamadas de uno a uno, con buzón de voz) y las Salas (hasta cincuenta
|
||||
personas, con vídeo, levantar la mano, grabación y emisión) son de Oasis. En Android
|
||||
el sonido y la cámara no los maneja Node: el servidor se conecta a un puente nativo de
|
||||
la app (`.audio_bridge`), que graba, reproduce, saca fotogramas de la cámara y emite
|
||||
ficheros.
|
||||

|
||||
|
||||
Ese puente está en la app Android del dev, que no tiene el código publicado. Por eso la
|
||||
APK de esta rama se monta sobre la oficial: se queda con su app tal cual y solo cambia
|
||||
lo que corre dentro (el backend con nuestra capa) y el nombre del paquete. Probado en
|
||||
el emulador: se entra en una sala y el micrófono graba.
|
||||
You can also receive a -Universal Basic Income- if you contribute to the Tribes and their coordinated actions.
|
||||
|
||||
En la barra de arriba, junto al avatar, está el botón de *No molestar* del Teléfono.
|
||||
+ https://ecoin.03c8.net
|
||||
|
||||
## Banking
|
||||
|
||||
---
|
||||
Oasis contains its own UBI (Universal Basic Income), distributed weekly using ECOin, and calculated by our AI through positive and efficient participation and trust.
|
||||
|
||||
## La interfaz
|
||||

|
||||
|
||||
----------
|
||||
|
||||

|
||||
## Carbon Footprinting
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/capturas/01-portada.png" width="200">
|
||||
<img src="docs/capturas/02-tribus.png" width="200">
|
||||
<img src="docs/capturas/03-votacion.png" width="200">
|
||||
<img src="docs/capturas/04-modulos.png" width="200">
|
||||
</p>
|
||||
<p align="center">
|
||||
<img src="docs/capturas/05-ajustes.png" width="200">
|
||||
<img src="docs/capturas/06-barra.png" width="200">
|
||||
<img src="docs/capturas/08-salas.png" width="200">
|
||||
<img src="docs/capturas/07-oasismobile.png" width="200">
|
||||
</p>
|
||||
Oasis contains its own carbon footprint meter.
|
||||
|
||||
La actividad al abrir, tribus, una votación de semáforo, módulos, ajustes, la barra de
|
||||
atajos, las salas del dev y el menú con el tema OasisMobile del dev.
|
||||

|
||||
|
||||
Casi sesenta módulos no caben en la pantalla de un teléfono. Al abrir la app se ve la
|
||||
actividad, lo que ha pasado en la red. Abajo hay cuatro atajos que eliges tú y una
|
||||
quinta pestaña fija, Menú, que abre desde abajo una hoja con todos los módulos por
|
||||
secciones, como el cajón de apps del móvil. Está en todas las pantallas. Por qué así,
|
||||
y no un menú arriba o una hamburguesa: [docs/ESTUDIO_NAVEGACION.md](docs/ESTUDIO_NAVEGACION.md).
|
||||
All transmissions, stored files, and interactions are measured to understand the environmental impact of the network.
|
||||
|
||||
Solo aparecen los módulos activos. En *Ajustes → Workflows* se eligen de golpe: el de
|
||||
acampada deja lo justo para organizarse. En el móvil un workflow cambia los módulos,
|
||||
no el aspecto: el aspecto se elige en *Ajustes → Tema* (Acampada u OasisMobile).
|
||||

|
||||
|
||||
---
|
||||
And also of each inhabitant.
|
||||
|
||||
## Cómo está hecha la rama
|
||||

|
||||
|
||||

|
||||
----------
|
||||
|
||||
Oasis lo desarrolla epsylon y saca versión casi cada día. Para no tener que rehacer la
|
||||
rama en cada una, lo nuestro va en ficheros propios: hojas de estilo, unas pocas
|
||||
vistas, un router que va delante del suyo y el envoltorio de Android. El backend, el
|
||||
middleware, los modelos y las traducciones son suyos y no se tocan. Las normas están
|
||||
en la [guía de desarrollo](GUIA_DE_DESARROLLO.md) y las cifras en
|
||||
[docs/FRONTERA_UPSTREAM.md](docs/FRONTERA_UPSTREAM.md).
|
||||
## L.A.R.P.
|
||||
|
||||
---
|
||||
Oasis contains a L.A.R.P. (real action role-playing) structured around 1+8 main houses.
|
||||
|
||||
## Empezar desde cero
|
||||

|
||||
|
||||

|
||||
The main objective is to empower the inhabitants to organize around specific proposals and generate federated governments with specific characteristics.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| [oasis-project.pub](https://oasis-project.pub) | Directorio de pubs, con invitaciones y estado de cada uno |
|
||||
| [0asis.net](https://0asis.net) | El proyecto explicado desde el principio |
|
||||
| [wiki.solarnethub.com](https://wiki.solarnethub.com/socialnet/overview) | La documentación oficial, módulo por módulo |
|
||||
+ https://wiki.solarnethub.com/socialnet/roleplaying#how_to_play
|
||||
|
||||
Check "The Houses" to review which one fit better with your ambitions:
|
||||
|
||||
---
|
||||
+ https://wiki.solarnethub.com/socialnet/roleplaying#the_houses
|
||||
|
||||
## Instalar
|
||||
----------
|
||||
|
||||
La APK no está en ninguna tienda: se descarga de las *releases* de este repositorio
|
||||
y se instala a mano, con orígenes desconocidos activado. Se instala junto al Oasis
|
||||
oficial sin pisarlo —el identificador es `net.laenre.oasis`—, así que puedes tener
|
||||
los dos y comparar.
|
||||
## Fediverse
|
||||
|
||||
Necesita unos 350 MB libres. El primer arranque tarda entre 15 y 60 segundos porque
|
||||
descomprime el servidor.
|
||||
Oasis bridges to the **Fediverse**. You can connect your fediverse accounts (ex: **Mastodon**) and use them for content from inside Oasis: read your home timeline, publish (text, images and video), reply, boost and favourite — without storing any third-party content in your SSB log (the feed is fetched live and shown ephemerally; only your credentials are kept locally).
|
||||
|
||||
> Verifica la huella de la firma antes de instalar una APK bajada de un servidor
|
||||
> propio: `apksigner verify --print-certs`
|
||||

|
||||
|
||||
### Compilar
|
||||
Connect your account from **Settings → Fediverse**, then open it from the **Fediverse** menu. Each network lives in its own space.
|
||||
|
||||
La APK se monta sobre la oficial de la misma versión. Hace falta Java 17 o más y las
|
||||
build-tools de Android (zipalign y apksigner); apktool se baja solo.
|
||||
+ Mastodon connect guide: [docs/FEDIVERSE/MASTODON/connect.md](docs/FEDIVERSE/MASTODON/connect.md)
|
||||
|
||||
```sh
|
||||
cd android
|
||||
./scripts/sobre_oficial.sh # baja la oficial, le pone el backend de la rama, la firma
|
||||
# para publicar, con la clave del hacklab:
|
||||
OASIS_KEYSTORE=... OASIS_KEYSTORE_PASS=... OASIS_KEY_ALIAS=... ./scripts/sobre_oficial.sh
|
||||
```
|
||||
----------
|
||||
|
||||
## Invite codes (for PUBs and TRIBES):
|
||||
|
||||
Sale en `android/build/sobre-oficial/oasis-<versión>-hacklab.apk`. Sin clave se firma
|
||||
con la de depuración y queda depurable, para poder inspeccionarla desde
|
||||
`chrome://inspect`.
|
||||
Oasis is a TRUSTNET. This means you need an invitation code to enter the PUBs (managed by inhabitants or hacklabs).
|
||||
|
||||
El proyecto Gradle propio de `android/` (`release.sh`, Android Studio) sigue ahí, pero
|
||||
ya no es el camino: no tiene el puente de audio y en él no hay llamadas.
|
||||
Similarly, TRIBES (groups in Oasis) require an entry code.
|
||||
|
||||

|
||||

|
||||
|
||||
While you can use it and connect to any nodes you want, it's a good idea to get an entry code to connect with the community.
|
||||
|
||||
---
|
||||
So you'll need to know someone, or participate in a collective action that distributes invitation codes, to see everything.
|
||||
|
||||
## Documentación
|
||||
+ https://wiki.solarnethub.com/socialnet/snh#finding_inhabitants
|
||||
|
||||
----------
|
||||
|
||||
Para desarrollar, en el propio repositorio:
|
||||
## Architecture:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| [GUIA_DE_DESARROLLO.md](GUIA_DE_DESARROLLO.md) | Qué se toca, qué no y cómo hacer cada cosa |
|
||||
| [docs/FRONTERA_UPSTREAM.md](docs/FRONTERA_UPSTREAM.md) | Cuánto cambia el dev en cada versión, medido |
|
||||
| [docs/USABILIDAD_PAD.txt](docs/USABILIDAD_PAD.txt) | Pruebas de usabilidad en el móvil |
|
||||
Oasis uses a gossip protocol or epidemic protocol which is a procedure or process of computer peer-to peer communication
|
||||
that is based on the way epidemics spread.
|
||||
|
||||
Todo lo de arriba, en detalle, está en la
|
||||
[wiki](https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki):
|
||||

|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| [Entender Oasis](https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki/Entender) | La red, el gossip, los saltos, los pubs, las tribus y el LARP |
|
||||
| [Empezar desde cero](https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki/Empezar) | Invitación, seguir gente, abrir tu blog o tu tienda |
|
||||
| [Llamadas](https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki/Llamadas) | Teléfono y Salas en el móvil |
|
||||
| [La interfaz](https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki/Interfaz) | Botón por botón |
|
||||
| [Identidades](https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki/Identidades) | Varias cuentas en el mismo móvil |
|
||||
| [Compilar la APK](https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki/Compilar) | Y cómo se publica una versión |
|
||||
| [Estructura del código](https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki/Estructura) | Dónde vive cada fichero y por qué ahí |
|
||||
| [Si algo va mal](https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki/Problemas) | Los fallos que han pasado de verdad |
|
||||
| [Qué NO tiene](https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki/Limitaciones) | Lo que falta respecto al oficial, sin adornos |
|
||||
This means that information is able to distribute across multiple machines, without requiring direct connections between them.
|
||||
|
||||
---
|
||||

|
||||
|
||||
## El proyecto original
|
||||
Even though Alice and Dan lack a direct connection, they can still exchange feeds:
|
||||
|
||||
Esta rama existe gracias a y por encima de el trabajo de epsylon. El código, la
|
||||
arquitectura, los módulos y la red son suyos.
|
||||

|
||||
|
||||
This is because gossip creates “transitive” connections between computers. Dan's messages travel through Carla and the PUB
|
||||
to reach Alice, and visa-versa.
|
||||
|
||||
- Código: https://github.com/epsylon/oasis
|
||||
- Proyecto: https://solarnethub.com
|
||||
- Wiki: https://wiki.solarnethub.com/socialnet/overview
|
||||
----------
|
||||
|
||||
## Licencia
|
||||
## Backend:
|
||||
|
||||
AGPL-3.0, la misma que el proyecto original.
|
||||
Oasis is based on a mesh network and self-hosted social media ecosystem called Secure Scuttlebutt (SSB).
|
||||
|
||||
SSB uses a blockchain like append-only data structure and a fully decentralized P2P network. There are no servers or authorities
|
||||
of any kind. Like a crypto transaction, SSB posts are censorship-resistant and are replicated to the entire network.
|
||||
|
||||

|
||||
|
||||
In SSB each user hosts their own content and the content of the peers they follow, which provides fault tolerance and
|
||||
eventual consistency.
|
||||
|
||||
----------
|
||||
|
||||
## Installing:
|
||||
|
||||
Follow ['INSTALL.md'](docs/install/install.md) to build and install it on your device.
|
||||
|
||||
----------
|
||||
|
||||
## Setup & Deploy:
|
||||
|
||||
Visit ['Settings'](https://wiki.solarnethub.com/socialnet/snh#settings_minimal) to learn how to choose your language, set a theme & configure your avatar.
|
||||
|
||||
----------
|
||||
|
||||
## SNH-Hub (for HackLabs):
|
||||
|
||||
The public content of the ['PUB: "La Plaza"'](https://wiki.solarnethub.com/socialnet/snh-pub) can be visited from outside the [project network](https://wiki.solarnethub.com/socialnet/overview), through the [World Wide Web](https://en.wikipedia.org/wiki/World_Wide_Web) (aka [Clearnet](https://en.wikipedia.org/wiki/Clearnet_(networking))).
|
||||
|
||||

|
||||
|
||||
Just visit: https://pub.solarnethub.com/
|
||||
|
||||

|
||||
|
||||
And also you can visit periodically the public statistic of the SNH-PUB:
|
||||
|
||||

|
||||
|
||||
See stats: https://laplaza.solarnethub.com/
|
||||
|
||||
----------
|
||||
|
||||
## Roadmap:
|
||||
|
||||
Review ['Roadmap'](https://wiki.solarnethub.com/project/roadmap#the_project_network) to know about some required functionalities that can be implemented.
|
||||
|
||||
----------
|
||||
|
||||
## Translations:
|
||||
|
||||
Oasis supports multiple languages. One way to contribute is to translate the interface into your language so other people in your region can use it more intuitively.
|
||||
|
||||
+ https://wiki.solarnethub.com/socialnet/snh#choose_language
|
||||
|
||||
----------
|
||||
|
||||
## Development:
|
||||
|
||||
Oasis is completely coded in: node.js, HTML5 + CSS.
|
||||
|
||||
Check ['Call 4 Hackers'](https://wiki.solarnethub.com/community/hackers) for contributing with developments.
|
||||
|
||||
----------
|
||||
|
||||
## Links:
|
||||
|
||||
+ SNH Website: https://solarnethub.com
|
||||
+ Kräkens.Lab: https://krakenslab.com
|
||||
+ Documentation: https://wiki.solarnethub.com
|
||||
+ Research: https://wiki.solarnethub.com/docs/research
|
||||
+ Code of Conduct: https://wiki.solarnethub.com/docs/code_of_conduct
|
||||
+ The KIT: https://wiki.solarnethub.com/kit/overview
|
||||
+ Ecosystem: https://wiki.solarnethub.com/socialnet/ecosystem
|
||||
+ Project Network: https://wiki.solarnethub.com/socialnet/snh#the_project_network
|
||||
+ Oasis: https://wiki.solarnethub.com/socialnet/overview
|
||||
+ ECOin: https://wiki.solarnethub.com/ecoin/overview
|
||||
+ Role-playing (L.A.R.P): https://wiki.solarnethub.com/socialnet/roleplaying
|
||||
+ Warehouse: https://wiki.solarnethub.com/stock/submit_request
|
||||
+ THS: https://thehackerstyle.com
|
||||
+ PeerTube: https://video.hardlimit.com/c/thehackerstyle/videos
|
||||
+ Youtube: https://www.youtube.com/@thehackerstyle
|
||||
+ Twitch: https://twitch.tv/thehackerstyle
|
||||
|
|
|
|||
7
android/.gitignore
vendored
|
|
@ -1,7 +0,0 @@
|
|||
# artefactos y binarios grandes: los prepara scripts/prepare.sh
|
||||
app/src/main/jniLibs/
|
||||
app/src/main/assets/nodejs-project.zip
|
||||
.gradle/
|
||||
build/
|
||||
app/build/
|
||||
local.properties
|
||||
|
|
@ -1,99 +0,0 @@
|
|||
# Wrapper Android propio
|
||||
|
||||
Proyecto Gradle para construir la APK de la rama sin depender del APK oficial como
|
||||
molde.
|
||||
|
||||
## Por que existe
|
||||
|
||||
Nota (19/10/2026): desde la 1.3.x la APK oficial ya pide microfono y camara y trae un
|
||||
puente nativo de audio y camara (`.audio_bridge`) para el Telefono y las Salas. Por eso
|
||||
la APK de la rama se monta ahora sobre la oficial con `scripts/sobre_oficial.sh`, y el
|
||||
proyecto Gradle de esta carpeta queda sin uso. Lo de abajo describe la situacion anterior.
|
||||
|
||||
El APK que se venia usando es el de epsylon re-firmado, y su `classes.dex` **no
|
||||
implementa `WebChromeClient.onPermissionRequest`**. Sin ese metodo el WebView de
|
||||
Android deniega por defecto toda peticion de `getUserMedia()`, de modo que las
|
||||
llamadas no pueden funcionar por mucho que se parchee el manifest:
|
||||
la lista de permisos es estatica y hay que declararla al compilar.
|
||||
|
||||
Este wrapper resuelve las dos puertas: declara los permisos y los concede al
|
||||
contenido web solo tras el consentimiento del usuario.
|
||||
|
||||
## Permisos: declarados, no concedidos
|
||||
|
||||
`CAMERA`, `RECORD_AUDIO` y `MODIFY_AUDIO_SETTINGS` figuran en el manifest porque
|
||||
Android exige que la lista sea fija. Pero son permisos peligrosos: **se piden en
|
||||
tiempo de ejecucion la primera vez que se pulsa Llamar**, no al instalar. Si nadie
|
||||
llama, no se pide nada y la app no accede a la camara ni al microfono.
|
||||
|
||||
`uses-feature ... required="false"` evita excluir de la tienda a dispositivos sin
|
||||
camara o sin microfono.
|
||||
|
||||
## Que reutiliza y que es nuevo
|
||||
|
||||
Las tres librerias nativas se toman tal cual del APK existente:
|
||||
|
||||
- `libnode.so` — nodejs-mobile **v18.20.4** oficial (OpenSSL 3.0.13+quic), un
|
||||
artefacto publico; no hay que compilar Node.
|
||||
- `libnative-lib.so` — 6,8 KB, un solo simbolo JNI:
|
||||
`Java_com_solarnethub_oasis_OasisActivity_startNodeWithArguments`.
|
||||
- `libc++_shared.so`
|
||||
|
||||
Por eso el `namespace` del modulo es `com.solarnethub.oasis`: JNI resuelve por
|
||||
nombre y la clase puente tiene que vivir en ese paquete. El `applicationId` si es
|
||||
propio (`net.laenre.oasis`), asi que la app se instala junto a la oficial sin
|
||||
pisarla.
|
||||
|
||||
Si algun dia se quiere soltar el `.so` de epsylon, reimplementar esos 6,8 KB son
|
||||
unas 40 lineas de C++ contra las cabeceras de nodejs-mobile, y ahi si haria falta
|
||||
el NDK.
|
||||
|
||||
## Mejoras sobre el APK actual
|
||||
|
||||
- `targetSdk 35` (el actual se quedo en uno ya caducado para la tienda).
|
||||
- Trafico en claro permitido **solo hacia loopback** via
|
||||
`network_security_config`, en vez de `usesCleartextTraffic="true"` global.
|
||||
- `allowBackup="false"`: `adb backup` deja de poder extraer `~/.ssb`.
|
||||
- El backend corre en un servicio en primer plano, en el proceso `:node`, asi que
|
||||
sobrevive en segundo plano — imprescindible para recibir llamadas. El APK actual
|
||||
declara `FOREGROUND_SERVICE` pero no registra ningun servicio.
|
||||
- Al descomprimir el backend se descartan las entradas con rutas sospechosas
|
||||
(zip slip).
|
||||
|
||||
## Construir
|
||||
|
||||
Con Android Studio basta con `./scripts/android-studio.sh`: clona el Oasis oficial,
|
||||
descarga su APK de la misma version y empaqueta el backend. Luego *File > Open >*
|
||||
esta carpeta y *Build > Build APK(s)*. A mano, paso a paso:
|
||||
|
||||
```sh
|
||||
# 1. dependencias del backend (una vez)
|
||||
(cd ../src/server && npm install)
|
||||
|
||||
# 2. librerias nativas + empaquetado del backend
|
||||
./scripts/prepare.sh ../../oasis_mobile # o la ruta de un APK
|
||||
|
||||
# 3. compilar
|
||||
./gradlew assembleDebug # o assembleRelease
|
||||
```
|
||||
|
||||
Para firmar la release, por entorno (la keystore nunca al repositorio):
|
||||
|
||||
```sh
|
||||
export OASIS_KEYSTORE=/ruta/oasis-alfa-key.jks
|
||||
export OASIS_KEYSTORE_PASS=...
|
||||
export OASIS_KEY_ALIAS=oasis
|
||||
./gradlew assembleRelease
|
||||
```
|
||||
|
||||
## Requisitos
|
||||
|
||||
JDK 17 o superior **con compilador**. El OpenJDK del sistema puede ser solo
|
||||
runtime; `gradle.properties` apunta al JDK que trae Android Studio. Cambia esa
|
||||
linea si compilas en otra maquina.
|
||||
|
||||
## Estado
|
||||
|
||||
Compila y genera APK con los permisos y el `onPermissionRequest` en su sitio.
|
||||
**Falta probarlo en un dispositivo real**: que arranque el backend, que la
|
||||
interfaz cargue y que `getUserMedia` resuelva al pulsar Llamar.
|
||||
|
|
@ -1,66 +0,0 @@
|
|||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
}
|
||||
|
||||
android {
|
||||
// El .so nativo exporta Java_com_solarnethub_oasis_OasisActivity_startNodeWithArguments,
|
||||
// asi que la clase puente tiene que vivir en ese paquete para que el simbolo resuelva.
|
||||
// El applicationId si es propio: la app se instala junto a la oficial sin pisarla.
|
||||
namespace = "com.solarnethub.oasis"
|
||||
compileSdk = 35
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "net.laenre.oasis"
|
||||
minSdk = 24
|
||||
targetSdk = 35
|
||||
// release.sh los pasa por entorno; a mano, el nombre sale de package.json (como
|
||||
// en el backend) para que la APK no diga ser otra version que la que lleva dentro
|
||||
val pkgVersion = Regex("\"version\"\\s*:\\s*\"([^\"]+)\"")
|
||||
.find(rootProject.file("../package.json").readText())?.groupValues?.get(1) ?: "0.0.0"
|
||||
versionCode = (System.getenv("OASIS_VERSION_CODE") ?: "1").toInt()
|
||||
versionName = System.getenv("OASIS_VERSION_NAME") ?: pkgVersion
|
||||
ndk { abiFilters += listOf("arm64-v8a") }
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
create("release") {
|
||||
val ks = System.getenv("OASIS_KEYSTORE")
|
||||
if (ks != null) {
|
||||
storeFile = file(ks)
|
||||
storePassword = System.getenv("OASIS_KEYSTORE_PASS")
|
||||
keyAlias = System.getenv("OASIS_KEY_ALIAS") ?: "oasis"
|
||||
keyPassword = System.getenv("OASIS_KEY_PASS") ?: System.getenv("OASIS_KEYSTORE_PASS")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
if (System.getenv("OASIS_KEYSTORE") != null) signingConfig = signingConfigs.getByName("release")
|
||||
}
|
||||
}
|
||||
|
||||
buildFeatures { buildConfig = true }
|
||||
|
||||
packaging {
|
||||
jniLibs {
|
||||
// Comprimidas, como en la APK oficial: libnode.so pasa de 50 a 16 MB en la
|
||||
// descarga. Sin comprimir (false) se cargaria por mmap desde la APK, pero eso
|
||||
// solo ahorra unos milisegundos al arrancar y cuesta 35 MB a cada descarga.
|
||||
useLegacyPackaging = true
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
kotlinOptions { jvmTarget = "17" }
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation("androidx.appcompat:appcompat:1.7.0")
|
||||
implementation("androidx.core:core-ktx:1.13.1")
|
||||
}
|
||||
|
|
@ -1,64 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<!-- descubrir otros moviles en la misma wifi: el anuncio LAN de SSB va por UDP
|
||||
broadcast/multicast, y Android lo filtra si no se coge un MulticastLock -->
|
||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<!-- solo para guardar descargas en Android 9 o anterior; desde el 10 no hace falta -->
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<!-- Camara y microfono para el Telefono y las Salas de Oasis.
|
||||
Se declaran aqui porque Android exige que la lista sea estatica, pero NO se
|
||||
conceden al instalar: son permisos peligrosos y se piden en tiempo de ejecucion
|
||||
la primera vez que se llama o se entra en una sala. Si nadie llama, no se pide nada y la app
|
||||
nunca accede a la camara ni al microfono. -->
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
||||
|
||||
<!-- required=false para no excluir de la tienda a dispositivos sin camara o sin microfono -->
|
||||
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
||||
<uses-feature android:name="android.hardware.microphone" android:required="false" />
|
||||
|
||||
<application
|
||||
android:label="@string/app_name"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:allowBackup="false"
|
||||
android:fullBackupContent="false"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.Oasis">
|
||||
|
||||
<!-- El backend Node vive en su propio proceso: asi sobrevive en segundo plano
|
||||
y se puede reiniciar limpio (nodejs-mobile solo permite arrancar node una
|
||||
vez por proceso). -->
|
||||
<service
|
||||
android:name="net.laenre.oasis.NodeService"
|
||||
android:process=":node"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
|
||||
<!-- La lista de configChanges tiene que ser completa: si falta una sola (y la
|
||||
que faltaba era smallestScreenSize), rotar destruye y recrea la Activity,
|
||||
el WebView se pierde y vuelve a cargar la URL, a veces antes de que el
|
||||
backend escuche. -->
|
||||
<activity
|
||||
android:name="net.laenre.oasis.MainActivity"
|
||||
android:exported="true"
|
||||
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|density|keyboardHidden|keyboard|navigation|uiMode|fontScale|layoutDirection"
|
||||
android:launchMode="singleTask"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
</application>
|
||||
</manifest>
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
package com.solarnethub.oasis
|
||||
|
||||
/**
|
||||
* Puente con el runtime de Node.
|
||||
*
|
||||
* El nombre de la clase y del metodo no son libres: libnative-lib.so exporta
|
||||
* Java_com_solarnethub_oasis_OasisActivity_startNodeWithArguments, y JNI resuelve
|
||||
* por nombre. De ahi que el namespace del modulo sea com.solarnethub.oasis aunque
|
||||
* el applicationId sea net.laenre.oasis.
|
||||
*
|
||||
* No es una Activity pese al nombre; solo declara el metodo nativo.
|
||||
*/
|
||||
object OasisActivity {
|
||||
init {
|
||||
System.loadLibrary("node")
|
||||
System.loadLibrary("native-lib")
|
||||
}
|
||||
|
||||
/** Arranca node en el hilo actual. No retorna hasta que el runtime termina. */
|
||||
@JvmStatic
|
||||
external fun startNodeWithArguments(arguments: Array<String>): Int
|
||||
}
|
||||
|
|
@ -1,304 +0,0 @@
|
|||
package net.laenre.oasis
|
||||
|
||||
import android.Manifest
|
||||
import android.app.DownloadManager
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.Color
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Environment
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import android.webkit.PermissionRequest
|
||||
import android.webkit.URLUtil
|
||||
import android.webkit.ValueCallback
|
||||
import android.webkit.WebChromeClient
|
||||
import android.webkit.WebResourceError
|
||||
import android.webkit.WebResourceRequest
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.Toast
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import com.solarnethub.oasis.BuildConfig
|
||||
|
||||
class MainActivity : AppCompatActivity() {
|
||||
|
||||
private lateinit var web: WebView
|
||||
private var pending: PermissionRequest? = null
|
||||
private var fileCallback: ValueCallback<Array<Uri>>? = null
|
||||
|
||||
private val pickFiles = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { res ->
|
||||
// Si se cancela hay que devolver null igualmente: si no, el <input> queda
|
||||
// bloqueado y no vuelve a abrir el selector nunca mas.
|
||||
fileCallback?.onReceiveValue(WebChromeClient.FileChooserParams.parseResult(res.resultCode, res.data))
|
||||
fileCallback = null
|
||||
}
|
||||
|
||||
private val askAndroid = registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { granted ->
|
||||
val req = pending
|
||||
pending = null
|
||||
if (req == null) return@registerForActivityResult
|
||||
// Solo se concede al contenido web lo que el sistema nos ha concedido a nosotros.
|
||||
val allowed = req.resources.filter { res ->
|
||||
when (res) {
|
||||
PermissionRequest.RESOURCE_VIDEO_CAPTURE -> granted[Manifest.permission.CAMERA] == true
|
||||
PermissionRequest.RESOURCE_AUDIO_CAPTURE -> granted[Manifest.permission.RECORD_AUDIO] == true
|
||||
else -> false
|
||||
}
|
||||
}.toTypedArray()
|
||||
if (allowed.isEmpty()) req.deny() else req.grant(allowed)
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
if (Build.VERSION.SDK_INT >= 33 && !has("android.permission.POST_NOTIFICATIONS")) {
|
||||
askAndroid.launch(arrayOf("android.permission.POST_NOTIFICATIONS"))
|
||||
}
|
||||
startService(Intent(this, NodeService::class.java))
|
||||
|
||||
web = WebView(this)
|
||||
setContentView(edgeToEdge(web))
|
||||
WebView.setWebContentsDebuggingEnabled(BuildConfig.DEBUG)
|
||||
|
||||
web.settings.apply {
|
||||
javaScriptEnabled = true
|
||||
domStorageEnabled = true
|
||||
mediaPlaybackRequiresUserGesture = false // permite reproducir el audio remoto de la llamada
|
||||
allowFileAccess = false
|
||||
allowContentAccess = false
|
||||
}
|
||||
|
||||
// La navegacion se queda dentro del backend local; lo de fuera va al navegador.
|
||||
web.webViewClient = object : WebViewClient() {
|
||||
override fun shouldOverrideUrlLoading(v: WebView?, r: WebResourceRequest?): Boolean {
|
||||
val u = r?.url ?: return false
|
||||
if (isLocal(u)) return false
|
||||
startActivity(Intent(Intent.ACTION_VIEW, u))
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Red de seguridad: si una pagina del backend no carga, se vuelve a esperar
|
||||
* y se reintenta, en vez de dejar el ERR_CONNECTION_REFUSED de Chromium en
|
||||
* pantalla. Pasa cuando el backend todavia no escucha (arranque lento), y
|
||||
* tambien cuando se ha reiniciado —al cambiar de identidad, o porque Android
|
||||
* mato el proceso del servicio estando en segundo plano—.
|
||||
*/
|
||||
override fun onPageFinished(v: WebView?, url: String?) {
|
||||
// se recuerda para poder volver aqui si luego hay que reintentar
|
||||
if (url != null && url.startsWith("http://127.0.0.1:3000")) lastGoodUrl = url
|
||||
}
|
||||
|
||||
override fun onReceivedError(v: WebView?, req: WebResourceRequest?, err: WebResourceError?) {
|
||||
if (req?.isForMainFrame != true) return // un css o una imagen no cuentan
|
||||
val u = req.url ?: return
|
||||
if (!isLocal(u)) return // paginas de fuera, ni tocarlas
|
||||
Log.w("OasisNode", "la pagina no ha cargado (${'$'}u); reintentando")
|
||||
loaded = false
|
||||
waitAndLoad()
|
||||
}
|
||||
}
|
||||
|
||||
// Sin este WebChromeClient.onPermissionRequest, el WebView DENIEGA por defecto
|
||||
// toda peticion de getUserMedia y no hay videollamada posible, aunque los
|
||||
// permisos esten declarados en el manifest. Es la puerta que faltaba.
|
||||
web.webChromeClient = object : WebChromeClient() {
|
||||
override fun onShowFileChooser(
|
||||
view: WebView?,
|
||||
callback: ValueCallback<Array<Uri>>?,
|
||||
params: FileChooserParams?
|
||||
): Boolean {
|
||||
fileCallback?.onReceiveValue(null) // descartar uno anterior sin resolver
|
||||
fileCallback = callback
|
||||
return try {
|
||||
pickFiles.launch(params?.createIntent())
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
fileCallback = null
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPermissionRequest(request: PermissionRequest) {
|
||||
runOnUiThread {
|
||||
if (!isLocal(request.origin)) { request.deny(); return@runOnUiThread }
|
||||
val needed = mutableListOf<String>()
|
||||
for (res in request.resources) {
|
||||
when (res) {
|
||||
PermissionRequest.RESOURCE_VIDEO_CAPTURE ->
|
||||
if (!has(Manifest.permission.CAMERA)) needed += Manifest.permission.CAMERA
|
||||
PermissionRequest.RESOURCE_AUDIO_CAPTURE ->
|
||||
if (!has(Manifest.permission.RECORD_AUDIO)) needed += Manifest.permission.RECORD_AUDIO
|
||||
}
|
||||
}
|
||||
if (needed.isEmpty()) {
|
||||
request.grant(request.resources)
|
||||
} else {
|
||||
// Aqui es donde el usuario ve el dialogo del sistema: al pulsar Llamar,
|
||||
// no al instalar la app.
|
||||
pending = request
|
||||
askAndroid.launch(needed.toTypedArray())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Lo que la pagina manda descargar (un PDF, un fichero adjunto) va al gestor de
|
||||
// descargas de Android; sin esto el WebView no hace nada al tocar el enlace.
|
||||
web.setDownloadListener { url, userAgent, disposition, mime, _ -> download(url, userAgent, disposition, mime) }
|
||||
|
||||
waitAndLoad()
|
||||
}
|
||||
|
||||
/**
|
||||
* La pantalla entera, por debajo de las barras del sistema (Android 15 lo impone
|
||||
* con targetSdk 35, y en versiones anteriores se pide igual para que se vea lo
|
||||
* mismo). El contenedor deja de margen lo que ocupan la barra de estado, la de
|
||||
* navegacion, el recorte de la camara y el teclado cuando esta abierto, asi la
|
||||
* barra de abajo de Oasis sube con el teclado en vez de quedar tapada.
|
||||
* El fondo blanco y los iconos oscuros son los del tema Acampada.
|
||||
*/
|
||||
private fun edgeToEdge(content: WebView): FrameLayout {
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
window.statusBarColor = Color.TRANSPARENT
|
||||
window.navigationBarColor = Color.TRANSPARENT
|
||||
val root = FrameLayout(this)
|
||||
root.setBackgroundColor(Color.WHITE)
|
||||
root.addView(content, FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT))
|
||||
WindowCompat.getInsetsController(window, root).apply {
|
||||
isAppearanceLightStatusBars = true
|
||||
isAppearanceLightNavigationBars = true
|
||||
}
|
||||
ViewCompat.setOnApplyWindowInsetsListener(root) { v, insets ->
|
||||
val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout())
|
||||
val ime = insets.getInsets(WindowInsetsCompat.Type.ime())
|
||||
v.setPadding(bars.left, bars.top, bars.right, maxOf(bars.bottom, ime.bottom))
|
||||
WindowInsetsCompat.CONSUMED
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
/** Descarga por el gestor de Android, a la carpeta Descargas, con aviso al terminar. */
|
||||
private fun download(url: String, userAgent: String?, disposition: String?, mime: String?) {
|
||||
try {
|
||||
val name = URLUtil.guessFileName(url, disposition, mime)
|
||||
val req = DownloadManager.Request(Uri.parse(url))
|
||||
.setTitle(name)
|
||||
.setMimeType(mime)
|
||||
.addRequestHeader("User-Agent", userAgent ?: "")
|
||||
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
|
||||
.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, name)
|
||||
(getSystemService(DOWNLOAD_SERVICE) as DownloadManager).enqueue(req)
|
||||
Toast.makeText(this, name, Toast.LENGTH_SHORT).show()
|
||||
} catch (e: Exception) {
|
||||
Log.w("OasisNode", "no se pudo descargar ${'$'}url: ${'$'}e")
|
||||
// si el gestor no puede, que lo abra otra aplicacion
|
||||
try { startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) } catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
|
||||
private fun has(p: String) =
|
||||
ContextCompat.checkSelfPermission(this, p) == PackageManager.PERMISSION_GRANTED
|
||||
|
||||
private fun isLocal(u: Uri?): Boolean {
|
||||
val h = u?.host ?: return false
|
||||
return (h == "127.0.0.1" || h == "localhost") && u.port == 3000
|
||||
}
|
||||
|
||||
private fun showStatus(attempt: Int) {
|
||||
val s = attempt / 2
|
||||
web.loadDataWithBaseURL(null, """
|
||||
<html><head><meta name='viewport' content='width=device-width,initial-scale=1'>
|
||||
<style>body{background:#fff;color:#000;font-family:sans-serif;display:flex;
|
||||
height:100vh;margin:0;align-items:center;justify-content:center;text-align:center}
|
||||
div{padding:24px}h2{text-transform:uppercase;border-bottom:3px solid #000;padding-bottom:6px}
|
||||
p{font-size:15px;line-height:1.5}</style></head>
|
||||
<body><div><h2>Oasis</h2>
|
||||
<p>Preparando la aplicación…<br>El primer arranque descomprime el
|
||||
servidor y puede tardar un minuto. No hace falta hacer nada.</p><p>${s}s</p></div></body></html>
|
||||
""".trimIndent(), "text/html", "utf-8", null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Carga la interfaz reintentando hasta que el backend responda.
|
||||
*
|
||||
* Se hace con el propio WebView en vez de sondear con HttpURLConnection: es el
|
||||
* canal que de verdad va a servir la app, y evita depender de que la peticion de
|
||||
* comprobacion pase los mismos filtros (el backend, por ejemplo, responde 400 a HEAD).
|
||||
*/
|
||||
private var loadAttempt = 0
|
||||
private var loaded = false
|
||||
@Volatile private var waiting = false // hay un hilo esperando al backend
|
||||
private var lastGoodUrl: String? = null // ultima pagina que cargo bien
|
||||
|
||||
/**
|
||||
* Espera a que el backend acepte conexiones y entonces carga la interfaz.
|
||||
*
|
||||
* La comprobacion es un socket TCP, no una peticion HTTP: no depende del metodo
|
||||
* (el backend responde 400 a HEAD), ni de redirecciones (/ redirige a /activity),
|
||||
* ni de la politica de trafico en claro. Si el puerto acepta, esta listo.
|
||||
*
|
||||
* La pantalla de estado se pinta ANTES de empezar a esperar, nunca entre medias:
|
||||
* cargarla mientras el WebView trae la pagina buena cancelaria esa carga.
|
||||
*/
|
||||
/** Ruta a abrir: la del intent si viene con una, o la raiz. */
|
||||
private fun targetUrl(): String {
|
||||
val d = intent?.data
|
||||
if (d != null && isLocal(d)) return d.toString()
|
||||
val path = intent?.getStringExtra("path")
|
||||
if (!path.isNullOrBlank() && path.startsWith("/")) return "http://127.0.0.1:3000${'$'}path"
|
||||
return "http://127.0.0.1:3000/"
|
||||
}
|
||||
|
||||
override fun onNewIntent(newIntent: Intent) {
|
||||
super.onNewIntent(newIntent)
|
||||
intent = newIntent
|
||||
if (loaded) web.loadUrl(targetUrl())
|
||||
}
|
||||
|
||||
private fun waitAndLoad() {
|
||||
// onReceivedError puede dispararse varias veces seguidas (la pagina y sus
|
||||
// recursos); sin esto se acumularian hilos esperando al mismo puerto
|
||||
if (waiting) return
|
||||
waiting = true
|
||||
showStatus(0)
|
||||
Log.i("OasisNode", "esperando al backend en 127.0.0.1:3000")
|
||||
Thread {
|
||||
var i = 0
|
||||
while (i < 600 && !loaded) { // hasta 5 minutos
|
||||
val ok = try {
|
||||
java.net.Socket().use { it.connect(java.net.InetSocketAddress("127.0.0.1", 3000), 1000); true }
|
||||
} catch (e: Exception) {
|
||||
if (i % 10 == 0) Log.w("OasisNode", "socket #" + i + ": " + e.javaClass.simpleName + ": " + e.message)
|
||||
false
|
||||
}
|
||||
if (ok) {
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
loaded = true
|
||||
waiting = false
|
||||
Log.i("OasisNode", "backend listo tras " + (i / 2) + "s, cargando interfaz")
|
||||
web.loadUrl(lastGoodUrl ?: targetUrl())
|
||||
}
|
||||
return@Thread
|
||||
}
|
||||
if (i % 8 == 0) Handler(Looper.getMainLooper()).post { if (!loaded) showStatus(i) }
|
||||
Thread.sleep(500); i++
|
||||
}
|
||||
waiting = false
|
||||
}.start()
|
||||
}
|
||||
|
||||
@Deprecated("Deprecated in Java")
|
||||
override fun onBackPressed() {
|
||||
if (web.canGoBack()) web.goBack() else super.onBackPressed()
|
||||
}
|
||||
}
|
||||
|
|
@ -1,139 +0,0 @@
|
|||
package net.laenre.oasis
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.wifi.WifiManager
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import android.system.Os
|
||||
import android.util.Log
|
||||
import com.solarnethub.oasis.OasisActivity
|
||||
import com.solarnethub.oasis.R
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.util.zip.ZipInputStream
|
||||
|
||||
/**
|
||||
* Servicio en primer plano que sostiene el backend Node.
|
||||
*
|
||||
* Vive en el proceso :node por dos razones: nodejs-mobile solo permite arrancar el
|
||||
* runtime una vez por proceso —cambiar de identidad exige matar el proceso, no vale
|
||||
* reiniciar dentro—, y asi el backend sobrevive cuando la app pasa a segundo plano,
|
||||
* que es imprescindible para recibir llamadas.
|
||||
*/
|
||||
class NodeService : Service() {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "OasisNode"
|
||||
private const val CHANNEL = "oasis-node"
|
||||
private const val NOTIF_ID = 1
|
||||
@Volatile private var started = false
|
||||
}
|
||||
|
||||
// Sin esto Android tira los paquetes multicast/broadcast que llegan por wifi y el
|
||||
// descubrimiento LAN de SSB no ve a nadie: dos moviles en la misma red no se encuentran.
|
||||
private var multicastLock: WifiManager.MulticastLock? = null
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
startForeground(NOTIF_ID, buildNotification())
|
||||
acquireMulticastLock()
|
||||
if (started) return
|
||||
started = true
|
||||
Thread({ runNode() }, "node-main").start()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
try { multicastLock?.takeIf { it.isHeld }?.release() } catch (e: Throwable) { Log.w(TAG, "soltando MulticastLock", e) }
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private fun acquireMulticastLock() {
|
||||
if (multicastLock?.isHeld == true) return
|
||||
try {
|
||||
val wifi = applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager
|
||||
multicastLock = wifi.createMulticastLock("oasis-lan").apply { setReferenceCounted(false); acquire() }
|
||||
} catch (e: Throwable) {
|
||||
// sin wifi o sin permiso: los pubs siguen funcionando, solo se pierde la LAN
|
||||
Log.w(TAG, "no se ha podido coger el MulticastLock; sin descubrimiento LAN", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildNotification(): Notification {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val ch = NotificationChannel(CHANNEL, getString(R.string.node_channel), NotificationManager.IMPORTANCE_LOW)
|
||||
ch.setShowBadge(false)
|
||||
(getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager).createNotificationChannel(ch)
|
||||
}
|
||||
val b = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) Notification.Builder(this, CHANNEL)
|
||||
else @Suppress("DEPRECATION") Notification.Builder(this)
|
||||
return b.setContentTitle(getString(R.string.app_name))
|
||||
.setContentText(getString(R.string.node_running))
|
||||
.setSmallIcon(android.R.drawable.stat_notify_sync)
|
||||
.setOngoing(true)
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun runNode() {
|
||||
try {
|
||||
val root = File(filesDir, "nodejs-project")
|
||||
extractProjectIfNeeded(root)
|
||||
val main = File(root, "main.js")
|
||||
if (!main.exists()) { Log.e(TAG, "no encuentro main.js en ${root.absolutePath}"); return }
|
||||
|
||||
// main.js hace process.env.HOME || resolve(__dirname,'..','..'). En Android HOME
|
||||
// suele venir como "/" —no escribible—, asi que se fija explicitamente al
|
||||
// almacenamiento privado de la app: ahi cuelga ~/.ssb.
|
||||
val home = filesDir.parentFile ?: filesDir
|
||||
val tmp = File(home, "tmp").apply { mkdirs() }
|
||||
Os.setenv("HOME", home.absolutePath, true)
|
||||
Os.setenv("TMPDIR", tmp.absolutePath, true)
|
||||
Log.i(TAG, "HOME=${home.absolutePath}")
|
||||
|
||||
val rc = OasisActivity.startNodeWithArguments(arrayOf("node", main.absolutePath))
|
||||
Log.w(TAG, "el runtime de node ha terminado con codigo $rc")
|
||||
} catch (e: Throwable) {
|
||||
Log.e(TAG, "fallo arrancando node", e)
|
||||
}
|
||||
}
|
||||
|
||||
/** Descomprime assets/nodejs-project.zip la primera vez y tras cada actualizacion. */
|
||||
private fun extractProjectIfNeeded(root: File) {
|
||||
val stamp = File(root, ".version")
|
||||
// Marca por tamano y fecha del asset, no por versionName: al reconstruir la APK
|
||||
// sin subir la version, con versionName la app seguiria con el backend anterior.
|
||||
// lastUpdateTime cambia en cada instalacion, tambien reinstalando la misma version.
|
||||
// (openFd no vale: el asset va comprimido dentro del APK y no admite descriptor.)
|
||||
val current = packageManager.getPackageInfo(packageName, 0).lastUpdateTime.toString()
|
||||
if (stamp.exists() && stamp.readText() == current) return
|
||||
if (root.exists()) root.deleteRecursively()
|
||||
root.mkdirs()
|
||||
assets.open("nodejs-project.zip").use { input ->
|
||||
ZipInputStream(input).use { zip ->
|
||||
var entry = zip.nextEntry
|
||||
while (entry != null) {
|
||||
val out = File(root, entry.name.removePrefix("nodejs-project/"))
|
||||
// no confiar en las rutas del zip: evita escribir fuera del directorio (zip slip)
|
||||
if (!out.canonicalPath.startsWith(root.canonicalPath + File.separator) &&
|
||||
out.canonicalPath != root.canonicalPath) {
|
||||
Log.w(TAG, "entrada de zip descartada por ruta sospechosa: ${entry.name}")
|
||||
} else if (entry.isDirectory) {
|
||||
out.mkdirs()
|
||||
} else {
|
||||
out.parentFile?.mkdirs()
|
||||
FileOutputStream(out).use { fos -> zip.copyTo(fos) }
|
||||
}
|
||||
zip.closeEntry()
|
||||
entry = zip.nextEntry
|
||||
}
|
||||
}
|
||||
}
|
||||
stamp.writeText(current)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Hexagono, el mismo motivo que la rejilla de navegacion de la interfaz -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp" android:height="108dp"
|
||||
android:viewportWidth="108" android:viewportHeight="108">
|
||||
<path
|
||||
android:pathData="M54,28 L74,40 L74,64 L54,76 L34,64 L34,40 Z"
|
||||
android:strokeColor="#FFB400" android:strokeWidth="5" android:fillColor="#00000000"/>
|
||||
<path
|
||||
android:pathData="M54,44 L63,49 L63,59 L54,64 L45,59 L45,49 Z"
|
||||
android:fillColor="#FFB400"/>
|
||||
</vector>
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#121212</color>
|
||||
</resources>
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Oasis</string>
|
||||
<string name="node_channel">Servicio de Oasis</string>
|
||||
<string name="node_running">Oasis esta activo</string>
|
||||
</resources>
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="Theme.Oasis" parent="Theme.AppCompat.NoActionBar">
|
||||
<item name="android:windowBackground">#FFFFFF</item>
|
||||
<item name="android:statusBarColor">#FFFFFF</item>
|
||||
<item name="android:windowLightStatusBar">true</item>
|
||||
</style>
|
||||
</resources>
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- El backend corre en 127.0.0.1:3000 sin TLS, asi que hay que permitir texto en
|
||||
claro, pero SOLO hacia loopback. El resto de la red queda en cifrado obligatorio.
|
||||
El orden importa: el esquema de Android exige base-config antes que domain-config. -->
|
||||
<network-security-config>
|
||||
<base-config cleartextTrafficPermitted="false" />
|
||||
<domain-config cleartextTrafficPermitted="true">
|
||||
<domain includeSubdomains="false">127.0.0.1</domain>
|
||||
<domain includeSubdomains="false">localhost</domain>
|
||||
</domain-config>
|
||||
</network-security-config>
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
plugins {
|
||||
id("com.android.application") version "8.5.2" apply false
|
||||
id("org.jetbrains.kotlin.android") version "1.9.24" apply false
|
||||
}
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
org.gradle.jvmargs=-Xmx2048m
|
||||
android.useAndroidX=true
|
||||
kotlin.code.style=official
|
||||
android.nonTransitiveRClass=true
|
||||
|
||||
# Gradle usa el JDK de JAVA_HOME (17 o superior, con javac). No se fija aqui la
|
||||
# ruta: cada maquina y el runner tienen el suyo.
|
||||
BIN
android/gradle/wrapper/gradle-wrapper.jar
vendored
|
|
@ -1,7 +0,0 @@
|
|||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
252
android/gradlew
vendored
|
|
@ -1,252 +0,0 @@
|
|||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015-2021 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh Gradle
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# This is normally unused
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
|
||||
' "$PWD" ) || exit
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
if ! command -v java >/dev/null 2>&1
|
||||
then
|
||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Collect all arguments for the java command:
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||
# and any embedded shellness will be escaped.
|
||||
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||
# treated as '${Hostname}' itself on the command line.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-classpath "$CLASSPATH" \
|
||||
org.gradle.wrapper.GradleWrapperMain \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
94
android/gradlew.bat
vendored
|
|
@ -1,94 +0,0 @@
|
|||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
@rem SPDX-License-Identifier: Apache-2.0
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
|
|
@ -1,76 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
#
|
||||
# Deja el proyecto listo para compilarlo desde Android Studio.
|
||||
#
|
||||
# Android Studio solo compila: no sabe traer el Oasis oficial, ni sacar libnode.so
|
||||
# del APK de epsylon, ni empaquetar el backend. Esto lo hace todo de una vez:
|
||||
#
|
||||
# 1. clona el Oasis oficial en ../upstream, en el commit de .upstream-commit
|
||||
# (de ahi salen las dependencias del backend, src/base/node_modules)
|
||||
# 2. descarga el APK oficial de la misma version a android/native/
|
||||
# (de ahi salen libnode.so y los modulos nativos de Node para Android)
|
||||
# 3. lanza prepare.sh, que copia las librerias y empaqueta el backend
|
||||
#
|
||||
# Uso, desde la raiz del repo o desde android/:
|
||||
# ./android/scripts/android-studio.sh
|
||||
#
|
||||
# Despues, en Android Studio: File > Open > la carpeta android/, esperar a que
|
||||
# termine el Gradle Sync, y Build > Build App Bundle(s) / APK(s) > Build APK(s).
|
||||
#
|
||||
# Cada vez que cambies algo en src/ o en main.js, vuelve a lanzarlo: la APK lleva
|
||||
# el backend empaquetado y Android Studio no lo rehace solo.
|
||||
set -euo pipefail
|
||||
|
||||
HERE="$(cd "$(dirname "$0")/.." && pwd)" # android/
|
||||
REPO="$(cd "$HERE/.." && pwd)" # raiz del repo
|
||||
UPSTREAM="${OASIS_UPSTREAM:-$REPO/../upstream}"
|
||||
|
||||
say() { printf '\n== %s\n' "$*"; }
|
||||
die() { printf '\n!! %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
for cmd in git node curl zip unzip; do
|
||||
command -v "$cmd" >/dev/null || die "falta '$cmd': instalalo y vuelve a lanzarlo"
|
||||
done
|
||||
|
||||
# --- 1) el Oasis oficial, en el mismo commit que src/ -----------------------------
|
||||
COMMIT="$(cat "$REPO/.upstream-commit")"
|
||||
if [ ! -d "$UPSTREAM/.git" ]; then
|
||||
say "clonando el Oasis oficial en $UPSTREAM (unos 300 MB, solo la primera vez)"
|
||||
git clone https://github.com/epsylon/oasis "$UPSTREAM"
|
||||
fi
|
||||
if [ "$(git -C "$UPSTREAM" rev-parse HEAD)" != "$COMMIT" ]; then
|
||||
say "poniendo el Oasis oficial en el commit $COMMIT"
|
||||
git -C "$UPSTREAM" fetch --quiet origin || true
|
||||
git -C "$UPSTREAM" checkout --quiet "$COMMIT"
|
||||
fi
|
||||
|
||||
# --- 2) el APK oficial de la misma version ----------------------------------------
|
||||
VERSION="$(node -p "require('$REPO/src/server/package.json').version")"
|
||||
BASE="$HERE/native/oasis-v$VERSION.apk"
|
||||
if [ ! -f "$BASE" ]; then
|
||||
say "descargando el APK oficial $VERSION (unos 50 MB, solo la primera vez)"
|
||||
mkdir -p "$HERE/native"
|
||||
curl -fL --progress-bar -o "$BASE.part" \
|
||||
"https://github.com/epsylon/oasis/releases/download/oasis-android-v$VERSION/oasis-v$VERSION.apk" \
|
||||
|| die "no se ha podido descargar el APK oficial de la $VERSION"
|
||||
mv "$BASE.part" "$BASE"
|
||||
fi
|
||||
|
||||
# --- 3) librerias y backend --------------------------------------------------------
|
||||
say "empaquetando el backend (un minuto o dos)"
|
||||
OASIS_UPSTREAM="$UPSTREAM" "$HERE/scripts/prepare.sh" "$BASE"
|
||||
|
||||
cat <<EOF
|
||||
|
||||
== listo
|
||||
|
||||
Ahora, en Android Studio:
|
||||
1. File > Open > $HERE
|
||||
2. espera a que termine el Gradle Sync (la primera vez descarga bastante)
|
||||
3. Build > Build App Bundle(s) / APK(s) > Build APK(s)
|
||||
|
||||
La APK sale en:
|
||||
$HERE/app/build/outputs/apk/debug/app-debug.apk
|
||||
|
||||
Si cambias algo en src/ o en main.js, vuelve a lanzar este script antes de compilar.
|
||||
EOF
|
||||
|
|
@ -1,81 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# Prepara lo que el proyecto Android no lleva en git:
|
||||
# - las librerias nativas (libnode.so son 49 MB; no tiene sentido versionarlas)
|
||||
# - assets/nodejs-project.zip con el backend Node y sus dependencias
|
||||
#
|
||||
# Uso: ./scripts/prepare.sh [ruta-a-un-apk-o-directorio-con-lib/arm64-v8a]
|
||||
set -euo pipefail
|
||||
|
||||
HERE="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
REPO="$(cd "$HERE/.." && pwd)"
|
||||
JNI="$HERE/app/src/main/jniLibs/arm64-v8a"
|
||||
ASSETS="$HERE/app/src/main/assets"
|
||||
SRC="${1:-}"
|
||||
|
||||
mkdir -p "$JNI" "$ASSETS"
|
||||
|
||||
# --- 1) librerias nativas -----------------------------------------------------
|
||||
need_libs=(libnode.so libnative-lib.so libc++_shared.so)
|
||||
missing=0
|
||||
for l in "${need_libs[@]}"; do [ -f "$JNI/$l" ] || missing=1; done
|
||||
|
||||
if [ "$missing" = "1" ]; then
|
||||
if [ -z "$SRC" ]; then
|
||||
echo "Faltan las librerias nativas en $JNI"
|
||||
echo "Pasa un APK o un directorio que tenga lib/arm64-v8a/, por ejemplo:"
|
||||
echo " ./scripts/prepare.sh ../../oasis_mobile"
|
||||
exit 1
|
||||
fi
|
||||
if [ -d "$SRC/lib/arm64-v8a" ]; then
|
||||
cp "$SRC/lib/arm64-v8a"/*.so "$JNI/"
|
||||
elif [ -f "$SRC" ]; then
|
||||
tmp="$(mktemp -d)"; unzip -qo "$SRC" 'lib/arm64-v8a/*' -d "$tmp"
|
||||
cp "$tmp/lib/arm64-v8a"/*.so "$JNI/"; rm -rf "$tmp"
|
||||
else
|
||||
echo "No encuentro librerias en $SRC"; exit 1
|
||||
fi
|
||||
echo "librerias nativas copiadas:"; ls -la "$JNI" | tail -n +2 | awk '{print " "$NF" ("$5" bytes)"}'
|
||||
fi
|
||||
|
||||
# --- 2) backend + dependencias ---------------------------------------------
|
||||
# El node_modules del movil esta podado: son ~137 MB frente a los 1,4 GB de una
|
||||
# instalacion de escritorio. En vez de reinstalarlo, se reutiliza el del APK base:
|
||||
# tiene que ser la APK oficial de la MISMA version que src/ (ahora 1.3.3): desde la
|
||||
# 1.1 el backend usa ssb-db2 y el node_modules de una 0.9.x ya no le sirve.
|
||||
BASE_ZIP=""
|
||||
if [ -n "$SRC" ] && [ -f "$SRC" ]; then
|
||||
tmpz="$(mktemp -d)"; unzip -qo "$SRC" 'assets/nodejs-project.zip' -d "$tmpz" 2>/dev/null || true
|
||||
[ -f "$tmpz/assets/nodejs-project.zip" ] && BASE_ZIP="$tmpz/assets/nodejs-project.zip"
|
||||
fi
|
||||
|
||||
work="$(mktemp -d)"
|
||||
if [ -n "$BASE_ZIP" ]; then
|
||||
( cd "$work" && unzip -q "$BASE_ZIP" )
|
||||
mv "$work/nodejs-project/src/server/node_modules" "$work/_nm"
|
||||
rm -rf "$work/nodejs-project/src"
|
||||
elif [ -d "$REPO/src/server/node_modules" ]; then
|
||||
mkdir -p "$work/nodejs-project"
|
||||
cp -r "$REPO/src/server/node_modules" "$work/_nm"
|
||||
else
|
||||
echo "No hay node_modules. Pasa un APK base del que sacarlo, o instalalas:"
|
||||
echo " (cd $REPO/src/server && npm install --omit=dev)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$work/nodejs-project"
|
||||
cp -r "$REPO/src" "$work/nodejs-project/"
|
||||
# backend.js lee README.md con readFileSync SIN try/catch (linea ~1690):
|
||||
# si falta, el backend muere con ENOENT al arrancar.
|
||||
cp "$REPO/main.js" "$REPO/package.json" "$REPO/README.md" "$work/nodejs-project/" 2>/dev/null || true
|
||||
test -f "$work/nodejs-project/README.md" || { echo "FALTA README.md: el backend no arrancaria"; exit 1; }
|
||||
rm -rf "$work/nodejs-project/src/server/node_modules"
|
||||
mv "$work/_nm" "$work/nodejs-project/src/server/node_modules"
|
||||
|
||||
# --- 3) empaquetar ------------------------------------------------------------
|
||||
# Con compresion: sin ella el backend son ~145 MB y la APK se iria por encima de 200.
|
||||
rm -f "$ASSETS/nodejs-project.zip"
|
||||
( cd "$work" && zip -q -r "$ASSETS/nodejs-project.zip" nodejs-project )
|
||||
rm -rf "$work" "${tmpz:-/nonexistent}"
|
||||
echo "backend empaquetado: $(du -h "$ASSETS/nodejs-project.zip" | cut -f1) en assets/nodejs-project.zip"
|
||||
echo
|
||||
echo "Listo. Ahora: ./gradlew assembleDebug (o assembleRelease para firmar)"
|
||||
|
|
@ -1,175 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
#
|
||||
# Construye la APK, la verifica y (si se le pasa un token) publica la release en
|
||||
# gitea. Pensado para lanzarlo a mano o desde un runner; hace lo mismo en los dos
|
||||
# sitios, que es lo que evita el clasico "en mi maquina si sale".
|
||||
#
|
||||
# Uso:
|
||||
# ./scripts/release.sh # compila debug y verifica
|
||||
# ./scripts/release.sh --release # compila release firmada
|
||||
# ./scripts/release.sh --release --publish # borrador, para revisarla
|
||||
# ./scripts/release.sh --release --publish --public # publicada: la descarga del README
|
||||
#
|
||||
# Variables:
|
||||
# OASIS_KEYSTORE, OASIS_KEYSTORE_PASS, OASIS_KEY_ALIAS firma (obligatorias con --release)
|
||||
# OASIS_GITEA_TOKEN publicar (obligatoria con --publish)
|
||||
# OASIS_APK_BASE APK del que sacar node_modules la primera vez
|
||||
# OASIS_GITEA_URL por defecto https://gitea.laenre.net
|
||||
# OASIS_GITEA_REPO por defecto hacklab/OASIS_MOBILE
|
||||
# ANDROID_HOME por defecto ~/Android/Sdk
|
||||
# JAVA_HOME un JDK 17 o superior CON compilador
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
HERE="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
REPO="$(cd "$HERE/.." && pwd)"
|
||||
cd "$HERE"
|
||||
|
||||
MODE=debug
|
||||
PUBLISH=0
|
||||
PUBLIC=0
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--release) MODE=release ;;
|
||||
--publish) PUBLISH=1 ;;
|
||||
--public) PUBLIC=1 ;;
|
||||
*) echo "opcion desconocida: $arg"; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
GITEA_URL="${OASIS_GITEA_URL:-https://gitea.laenre.net}"
|
||||
GITEA_REPO="${OASIS_GITEA_REPO:-hacklab/OASIS_MOBILE}"
|
||||
SDK="${ANDROID_HOME:-$HOME/Android/Sdk}"
|
||||
|
||||
say() { printf '\n== %s\n' "$*"; }
|
||||
die() { printf '\n!! %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
# --- 1) version -------------------------------------------------------------
|
||||
# El nombre sale de package.json, que es de donde lo saca tambien el backend, para
|
||||
# que la version que muestra la aplicacion y la de la APK no puedan discrepar.
|
||||
VERSION_NAME="${OASIS_VERSION_NAME:-$(node -p "require('$REPO/package.json').version" 2>/dev/null || echo 0.0.0)}"
|
||||
# El codigo tiene que subir en cada publicacion o Android rechaza la instalacion:
|
||||
# el numero de commits sirve y no hay que llevarlo a mano.
|
||||
VERSION_CODE="${OASIS_VERSION_CODE:-$(git -C "$REPO" rev-list --count HEAD 2>/dev/null || echo 1)}"
|
||||
TAG="v${VERSION_NAME}-${VERSION_CODE}"
|
||||
export OASIS_VERSION_NAME="$VERSION_NAME" OASIS_VERSION_CODE="$VERSION_CODE"
|
||||
say "version ${VERSION_NAME} (codigo ${VERSION_CODE}), modo ${MODE}"
|
||||
|
||||
# --- 2) comprobaciones previas ---------------------------------------------
|
||||
[ -n "${JAVA_HOME:-}" ] || die "define JAVA_HOME apuntando a un JDK con compilador"
|
||||
[ -x "${JAVA_HOME}/bin/javac" ] || die "en ${JAVA_HOME} no hay javac: es un runtime, no un JDK"
|
||||
# apksigner y aapt2 son scripts que llaman a "java" a pelo: sin esto fallaban con
|
||||
# "java: no encontrado" y parecia que la APK estaba mal firmada
|
||||
export PATH="$JAVA_HOME/bin:$PATH"
|
||||
[ -d "$SDK" ] || die "no encuentro el SDK de Android en $SDK (define ANDROID_HOME)"
|
||||
|
||||
if [ "$MODE" = release ]; then
|
||||
[ -n "${OASIS_KEYSTORE:-}" ] || die "--release necesita OASIS_KEYSTORE"
|
||||
[ -f "$OASIS_KEYSTORE" ] || die "no existe el almacen de claves: $OASIS_KEYSTORE"
|
||||
[ -n "${OASIS_KEYSTORE_PASS:-}" ] || die "--release necesita OASIS_KEYSTORE_PASS"
|
||||
fi
|
||||
if [ "$PUBLISH" = 1 ]; then
|
||||
[ -n "${OASIS_GITEA_TOKEN:-}" ] || die "--publish necesita OASIS_GITEA_TOKEN"
|
||||
[ "$MODE" = release ] || die "no se publica una APK de depuracion"
|
||||
fi
|
||||
|
||||
# --- 3) backend empaquetado -------------------------------------------------
|
||||
# prepare.sh es idempotente pero tarda; se rehace siempre porque el backend cambia
|
||||
# en cada commit y una APK con el backend viejo es peor que no tener APK.
|
||||
# La primera vez hace falta un APK base del que sacar node_modules: el del movil
|
||||
# esta podado y sin binarios nativos, y un npm install normal no lo reproduce.
|
||||
say "empaquetando el backend"
|
||||
./scripts/prepare.sh ${OASIS_APK_BASE:+"$OASIS_APK_BASE"} \
|
||||
|| die "prepare.sh ha fallado (define OASIS_APK_BASE con un APK del que sacar node_modules)"
|
||||
|
||||
ZIP=app/src/main/assets/nodejs-project.zip
|
||||
[ -f "$ZIP" ] || die "falta $ZIP"
|
||||
ZIP_MB=$(( $(stat -c%s "$ZIP") / 1024 / 1024 ))
|
||||
[ "$ZIP_MB" -ge 20 ] || die "el backend empaquetado son $ZIP_MB MB: se ha quedado a medias"
|
||||
say "backend: ${ZIP_MB} MB"
|
||||
|
||||
# --- 4) compilar ------------------------------------------------------------
|
||||
say "compilando"
|
||||
if [ "$MODE" = release ]; then
|
||||
./gradlew --quiet assembleRelease
|
||||
APK=$(ls app/build/outputs/apk/release/*.apk | head -1)
|
||||
else
|
||||
./gradlew --quiet assembleDebug
|
||||
APK=$(ls app/build/outputs/apk/debug/*.apk | head -1)
|
||||
fi
|
||||
[ -f "$APK" ] || die "no se ha generado ninguna APK"
|
||||
say "APK: $APK ($(( $(stat -c%s "$APK") / 1024 / 1024 )) MB)"
|
||||
|
||||
# --- 5) verificar -----------------------------------------------------------
|
||||
# Estas tres comprobaciones son las que han cazado fallos de verdad: una APK que
|
||||
# compila e instala puede no llevar el backend dentro, o haber perdido los permisos.
|
||||
BT=$(ls -d "$SDK"/build-tools/* 2>/dev/null | sort -V | tail -1)
|
||||
[ -n "$BT" ] || die "no encuentro build-tools en $SDK"
|
||||
|
||||
say "verificando"
|
||||
# ojo con pipefail: grep -q cierra la tuberia y el productor muere con SIGPIPE,
|
||||
# asi que se vuelca a fichero antes de mirar
|
||||
LISTING=$(mktemp); PERMS=$(mktemp)
|
||||
trap 'rm -f "$LISTING" "$PERMS"' EXIT
|
||||
unzip -l "$APK" > "$LISTING"
|
||||
"$BT/aapt2" dump permissions "$APK" > "$PERMS" 2>/dev/null || true
|
||||
|
||||
grep -q "assets/nodejs-project.zip" "$LISTING" || die "la APK no lleva el backend dentro"
|
||||
grep -q "android.permission.CAMERA" "$PERMS" || die "la APK no declara CAMERA: no podria hacer videollamadas"
|
||||
grep -q "android.permission.RECORD_AUDIO" "$PERMS" || die "la APK no declara RECORD_AUDIO"
|
||||
grep -q "libnode.so" "$LISTING" || die "la APK no lleva el runtime de node"
|
||||
grep -q "CHANGE_WIFI_MULTICAST_STATE" "$PERMS" || die "la APK no declara CHANGE_WIFI_MULTICAST_STATE: no veria otros moviles en la misma wifi"
|
||||
|
||||
# Una APK debuggable deja leer por adb los datos de la app, clave secreta incluida.
|
||||
if "$BT/aapt2" dump badging "$APK" 2>/dev/null | grep -q "application-debuggable"; then
|
||||
[ "$MODE" = release ] && die "la APK release ha salido debuggable"
|
||||
say "OJO: APK de depuracion (debuggable). Solo para probar en tu movil, no la repartas"
|
||||
fi
|
||||
|
||||
# La oficial pesa ~54 MB; si esta se dispara es que prepare.sh ha dejado de adelgazar
|
||||
APK_MB=$(( $(stat -c%s "$APK") / 1024 / 1024 ))
|
||||
[ "$APK_MB" -le 80 ] || say "OJO: la APK son ${APK_MB} MB (la oficial ~54): revisa que mete prepare.sh"
|
||||
|
||||
if [ "$MODE" = release ]; then
|
||||
"$BT/apksigner" verify --print-certs "$APK" > /tmp/oasis-signer.txt 2>&1 \
|
||||
|| { cat /tmp/oasis-signer.txt >&2; die "la APK no esta firmada correctamente"; }
|
||||
FINGERPRINT=$(grep -m1 "SHA-256 digest" /tmp/oasis-signer.txt | awk '{print $NF}')
|
||||
say "huella del certificado: $FINGERPRINT"
|
||||
fi
|
||||
|
||||
SHA=$(sha256sum "$APK" | cut -d' ' -f1)
|
||||
say "sha256 de la APK: $SHA"
|
||||
|
||||
# --- 6) publicar ------------------------------------------------------------
|
||||
if [ "$PUBLISH" != 1 ]; then
|
||||
say "listo (sin publicar)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
say "publicando $TAG en $GITEA_REPO"
|
||||
API="$GITEA_URL/api/v1/repos/$GITEA_REPO"
|
||||
AUTH="Authorization: token $OASIS_GITEA_TOKEN"
|
||||
|
||||
NOTES=$(printf 'Rama experimental del hacklab sobre el Oasis de epsylon.\n\nNO es Oasis oficial: https://github.com/epsylon/oasis\n\nVersion base: %s (codigo %s)\n\nsha256 de la APK: %s\nHuella del certificado: %s\n\nComprueba la huella antes de instalar:\n\n apksigner verify --print-certs oasis.apk\n' \
|
||||
"$VERSION_NAME" "$VERSION_CODE" "$SHA" "${FINGERPRINT:-sin firmar}")
|
||||
|
||||
RELEASE_ID=$(curl -sf -X POST "$API/releases" -H "$AUTH" -H "Content-Type: application/json" \
|
||||
-d "$(node -e '
|
||||
const [tag, name, body] = process.argv.slice(1);
|
||||
const pub = process.argv[4] === "1";
|
||||
process.stdout.write(JSON.stringify({ tag_name: tag, name, body, draft: !pub, prerelease: !pub }));
|
||||
' "$TAG" "Oasis Mobile $TAG" "$NOTES" "$PUBLIC")" | node -pe 'JSON.parse(require("fs").readFileSync(0)).id' 2>/dev/null) \
|
||||
|| die "no se ha podido crear la release (token sin permiso, o el tag ya existe)"
|
||||
|
||||
# Siempre con el mismo nombre: el README enlaza a releases/download/latest/oasis-mobile.apk,
|
||||
# y la version ya va en el tag y en el titulo de la release.
|
||||
curl -sf -X POST "$API/releases/$RELEASE_ID/assets?name=oasis-mobile.apk" \
|
||||
-H "$AUTH" -F "attachment=@$APK" >/dev/null \
|
||||
|| die "la release se ha creado pero no se ha podido subir la APK"
|
||||
|
||||
if [ "$PUBLIC" = 1 ]; then
|
||||
say "publicada: $GITEA_URL/$GITEA_REPO/releases/latest"
|
||||
else
|
||||
say "publicada como borrador: $GITEA_URL/$GITEA_REPO/releases"
|
||||
say "revisala y quita el borrador a mano (o usa --public); no se publica sola a proposito"
|
||||
fi
|
||||
|
|
@ -1,126 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
#
|
||||
# Construye la APK de la rama encima de la APK oficial del dev.
|
||||
#
|
||||
# Desde la 1.3.x la app Android de epsylon trae cosas que la nuestra no tiene: el
|
||||
# puente nativo de audio y camara (.audio_bridge) que usan el Telefono y las Salas,
|
||||
# las notificaciones de llamada y sus permisos. Su codigo no esta publicado, asi que
|
||||
# en vez de imitarlo se parte de su APK:
|
||||
#
|
||||
# 1. se baja la APK oficial de la misma version (github, epsylon/oasis)
|
||||
# 2. se desmonta con apktool
|
||||
# 3. su backend (assets/nodejs-project.zip) se cambia por el de la rama, conservando
|
||||
# su node_modules, que es el podado para el movil
|
||||
# 4. se le cambia el paquete a net.laenre.oasis (y lo que depende de el) para que
|
||||
# conviva con la oficial
|
||||
# 5. se vuelve a montar, se alinea y se firma
|
||||
#
|
||||
# Uso:
|
||||
# ./scripts/sobre_oficial.sh # firma con la clave de depuracion
|
||||
# OASIS_KEYSTORE=... OASIS_KEYSTORE_PASS=... OASIS_KEY_ALIAS=... ./scripts/sobre_oficial.sh
|
||||
#
|
||||
# Variables:
|
||||
# OASIS_VERSION version del dev (por defecto la de package.json)
|
||||
# OASIS_APK_OFICIAL ruta a la APK oficial, si ya se tiene (si no, se baja)
|
||||
# ANDROID_HOME por defecto ~/Android/Sdk
|
||||
# JAVA_HOME un JDK 17 o superior
|
||||
set -euo pipefail
|
||||
|
||||
HERE="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
REPO="$(cd "$HERE/.." && pwd)"
|
||||
SDK="${ANDROID_HOME:-$HOME/Android/Sdk}"
|
||||
JAVA="${JAVA_HOME:+$JAVA_HOME/bin/}java"
|
||||
TOOLS="$HERE/.tools"
|
||||
OUT="$HERE/build/sobre-oficial"
|
||||
PKG_DEV="com.solarnethub.oasis"
|
||||
PKG="net.laenre.oasis"
|
||||
APKTOOL_VER="3.0.3"
|
||||
APKTOOL_SHA="dbf930b076c6b9be08d57c449cacefc3bdd6b71ebd59b3066fc0e1f5b14f9423"
|
||||
|
||||
say() { printf '\n== %s\n' "$*"; }
|
||||
die() { printf '\n!! %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
VERSION="${OASIS_VERSION:-$(node -p "require('$REPO/package.json').version")}"
|
||||
BT="$(ls -d "$SDK"/build-tools/*/ | sort -V | tail -1)"
|
||||
[ -x "$BT/zipalign" ] || die "no encuentro zipalign en $BT"
|
||||
mkdir -p "$TOOLS" "$OUT"
|
||||
|
||||
# --- apktool, version fija y comprobada ---------------------------------------
|
||||
APKTOOL="$TOOLS/apktool_$APKTOOL_VER.jar"
|
||||
if [ ! -f "$APKTOOL" ]; then
|
||||
say "bajando apktool $APKTOOL_VER"
|
||||
curl -fsSL -o "$APKTOOL" "https://github.com/iBotPeaches/Apktool/releases/download/v$APKTOOL_VER/apktool_$APKTOOL_VER.jar"
|
||||
fi
|
||||
echo "$APKTOOL_SHA $APKTOOL" | sha256sum -c --quiet - || die "apktool no coincide con la huella esperada"
|
||||
|
||||
# --- la APK oficial -------------------------------------------------------------
|
||||
OFICIAL="${OASIS_APK_OFICIAL:-$TOOLS/oasis-v$VERSION.apk}"
|
||||
if [ ! -f "$OFICIAL" ]; then
|
||||
say "bajando la APK oficial $VERSION"
|
||||
curl -fsSL -o "$OFICIAL" "https://github.com/epsylon/oasis/releases/download/oasis-android-v$VERSION/oasis-v$VERSION.apk"
|
||||
fi
|
||||
say "APK oficial: $OFICIAL"
|
||||
"$BT/apksigner" verify --print-certs "$OFICIAL" | grep -m2 -E "certificate DN|SHA-256" || die "la APK oficial no verifica"
|
||||
|
||||
# --- desmontar ------------------------------------------------------------------
|
||||
say "desmontando"
|
||||
rm -rf "$OUT/dec"
|
||||
"$JAVA" -jar "$APKTOOL" d -q -f -o "$OUT/dec" "$OFICIAL"
|
||||
DEC="$OUT/dec"
|
||||
|
||||
# --- backend de la rama, con el node_modules del dev ---------------------------
|
||||
say "cambiando el backend por el de la rama"
|
||||
work="$(mktemp -d)"
|
||||
( cd "$work" && unzip -q "$DEC/assets/nodejs-project.zip" )
|
||||
mv "$work/nodejs-project/src/server/node_modules" "$work/_nm"
|
||||
rm -rf "$work/nodejs-project/src"
|
||||
cp -r "$REPO/src" "$work/nodejs-project/src"
|
||||
rm -rf "$work/nodejs-project/src/server/node_modules"
|
||||
mv "$work/_nm" "$work/nodejs-project/src/server/node_modules"
|
||||
cp "$REPO/main.js" "$REPO/package.json" "$REPO/README.md" "$work/nodejs-project/"
|
||||
rm -f "$work/nodejs-project/.phone_state.json"
|
||||
rm -f "$DEC/assets/nodejs-project.zip"
|
||||
( cd "$work" && zip -q -r "$DEC/assets/nodejs-project.zip" nodejs-project )
|
||||
rm -rf "$work"
|
||||
|
||||
# --- paquete propio, para convivir con la oficial ------------------------------
|
||||
# Las clases se quedan como estan (com.solarnethub.oasis.OasisActivity...); cambia
|
||||
# el paquete, los authorities, el permiso propio y los nombres de las acciones de
|
||||
# llamada, que si no se cruzarian con los de la app oficial.
|
||||
say "paquete $PKG"
|
||||
sed -i \
|
||||
-e "s|package=\"$PKG_DEV\"|package=\"$PKG\"|" \
|
||||
-e "s|$PKG_DEV.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION|$PKG.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION|g" \
|
||||
-e "s|android:authorities=\"$PKG_DEV.|android:authorities=\"$PKG.|g" \
|
||||
"$DEC/AndroidManifest.xml"
|
||||
grep -rl "\"$PKG_DEV\.[A-Z_]*\"" "$DEC"/smali* | xargs -r sed -i -E "s|\"$PKG_DEV\.([A-Z_]+)\"|\"$PKG.\1\"|g"
|
||||
|
||||
# las de prueba (firmadas con la clave de depuracion) se marcan depurables: asi se
|
||||
# puede inspeccionar la WebView desde chrome://inspect. Las de publicar, nunca
|
||||
if [ -z "${OASIS_KEYSTORE:-}" ]; then
|
||||
sed -i 's|<application |<application android:debuggable="true" |' "$DEC/AndroidManifest.xml"
|
||||
fi
|
||||
|
||||
# version: la del dev por mil mas los commits de la rama, para que suba siempre
|
||||
CODE_DEV="$(sed -n 's/^ versionCode: //p' "$DEC/apktool.yml")"
|
||||
CODE="$(( CODE_DEV * 1000 + $(git -C "$REPO" rev-list --count HEAD) % 1000 ))"
|
||||
sed -i -e "s/^ versionCode: .*/ versionCode: $CODE/" -e "s/^ versionName: .*/ versionName: $VERSION-hacklab/" "$DEC/apktool.yml"
|
||||
say "version $VERSION-hacklab (codigo $CODE)"
|
||||
|
||||
# --- montar, alinear y firmar --------------------------------------------------
|
||||
say "montando"
|
||||
"$JAVA" -jar "$APKTOOL" b -q -o "$OUT/sin-firmar.apk" "$DEC"
|
||||
"$BT/zipalign" -f -p 4 "$OUT/sin-firmar.apk" "$OUT/alineada.apk"
|
||||
FINAL="$OUT/oasis-$VERSION-hacklab.apk"
|
||||
if [ -n "${OASIS_KEYSTORE:-}" ]; then
|
||||
"$BT/apksigner" sign --ks "$OASIS_KEYSTORE" --ks-pass "env:OASIS_KEYSTORE_PASS" \
|
||||
--ks-key-alias "${OASIS_KEY_ALIAS:-oasis}" --out "$FINAL" "$OUT/alineada.apk"
|
||||
else
|
||||
"$BT/apksigner" sign --ks "$HOME/.android/debug.keystore" --ks-pass pass:android \
|
||||
--ks-key-alias androiddebugkey --out "$FINAL" "$OUT/alineada.apk"
|
||||
fi
|
||||
rm -f "$OUT/sin-firmar.apk" "$OUT/alineada.apk" "$FINAL.idsig"
|
||||
"$BT/apksigner" verify "$FINAL" || die "la APK firmada no verifica"
|
||||
|
||||
say "APK: $FINAL ($(du -h "$FINAL" | cut -f1))"
|
||||
echo "sha256: $(sha256sum "$FINAL" | cut -d' ' -f1)"
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
pluginManagement {
|
||||
repositories { google(); mavenCentral(); gradlePluginPortal() }
|
||||
}
|
||||
dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||
repositories { google(); mavenCentral() }
|
||||
}
|
||||
rootProject.name = "OasisMobile"
|
||||
include(":app")
|
||||
|
|
@ -1,106 +0,0 @@
|
|||
# Cómo moverse por Oasis en el móvil
|
||||
|
||||
Estudio para decidir la navegación de la rama. 19/10/2026.
|
||||
|
||||
## La pregunta
|
||||
|
||||
Oasis tiene casi sesenta módulos. En un escritorio caben en un menú lateral; en un
|
||||
móvil no. ¿Qué tiene que ver alguien al abrir la app, y cómo llega al resto?
|
||||
|
||||
## Lo que hay ahora
|
||||
|
||||
Medido en el emulador (Pixel 9, 412 px de ancho) con el workflow acampada (16 módulos):
|
||||
|
||||
- Al abrir, la portada es el lanzador: una rejilla de iconos por secciones que ocupa
|
||||
unos 1.300 px, casi pantalla y media. La actividad, que es lo que ha pasado en la
|
||||
red, empieza debajo y no se ve sin bajar.
|
||||
- Abajo hay una barra de cuatro atajos que elige cada cual (Actividad, Chats,
|
||||
Publicar, Buzón de serie).
|
||||
- Para ir a un módulo que no está en la barra hay que volver a la portada y buscarlo
|
||||
en la rejilla. Desde dentro de un módulo no hay acceso directo al resto.
|
||||
|
||||
O sea: la portada es un menú, no un sitio donde pase algo, y el menú solo está en la
|
||||
portada.
|
||||
|
||||
## Cómo lo hacen otras
|
||||
|
||||
- Instagram, TikTok, X: el contenido es la portada. Abajo, cinco destinos fijos
|
||||
(inicio, buscar, crear, vídeos, perfil). Lo demás vive en el perfil o en ajustes.
|
||||
- WhatsApp, Signal, Telegram: pestañas con las tres o cuatro cosas que se usan
|
||||
siempre. Lo demás, en un menú de tres puntos.
|
||||
- Google Maps, Gmail y la guía de Material Design: una barra abajo con entre tres y
|
||||
cinco destinos; si hay más, el último es "Más" y abre una hoja con el resto.
|
||||
- El menú hamburguesa arriba a la izquierda (muy usado en 2012-2016) se ha ido
|
||||
abandonando: lo que no se ve se usa menos, y arriba queda lejos del pulgar.
|
||||
|
||||
## Qué pedimos a la navegación
|
||||
|
||||
1. Al abrir, ver qué pasa (actividad), no un menú.
|
||||
2. Llegar a cualquier módulo desde cualquier pantalla, en dos toques como mucho.
|
||||
3. Que el sitio para hacerlo se vea sin explicarlo.
|
||||
4. Que se alcance con el pulgar con el móvil en una mano.
|
||||
5. No tapar contenido ni quitar sitio de pantalla.
|
||||
|
||||
## Opciones
|
||||
|
||||
| | Portada | Llegar a un módulo | Pulgar | Se ve | Comentario |
|
||||
|---|---|---|---|---|---|
|
||||
| A. Como ahora | Lanzador | Volver a portada y buscar | Medio | Sí, en portada | La actividad queda enterrada |
|
||||
| B. Botón "Menú" bajo la barra de arriba | Actividad | 2 toques | Lejos (arriba) | Sí | La idea inicial; ocupa una fila arriba |
|
||||
| C. "Menú" como quinta pestaña de abajo | Actividad | 2 toques | Cerca | Sí | Patrón "Más" de Android y de las apps grandes |
|
||||
| D. Hamburguesa arriba | Actividad | 2 toques | Lejos | Poco | Lo que se esconde se usa menos |
|
||||
|
||||
## Recomendación
|
||||
|
||||
C: la portada pasa a ser la actividad, y la barra de abajo tiene siempre una quinta
|
||||
pestaña, "Menú", que abre desde abajo una hoja con todos los módulos agrupados (el
|
||||
mismo lanzador de ahora). Se cierra tocando fuera, con la X o volviendo a tocar Menú.
|
||||
|
||||
Es la idea de B, un botón que despliega las opciones, pero puesto donde llega el
|
||||
pulgar y donde está el resto de la navegación. Funciona igual en todas las pantallas.
|
||||
Está hecho con una casilla y CSS; un script mínimo hace que el botón atrás de Android
|
||||
cierre la hoja en vez de cambiar de página.
|
||||
|
||||
## Lo que salió en la prueba (emulador, 19/10/2026)
|
||||
|
||||
- Portada a Tribus: dos toques (Menú, Tribus). Desde Foros a Votaciones: dos toques.
|
||||
- En la primera versión la hoja dejaba ver 690 px y los módulos medían 1.200: Tribus
|
||||
quedaba fuera. Con cinco iconos por fila y los grupos pequeños de dos en dos, los 20
|
||||
módulos caben a 412 y a 360 px.
|
||||
- Se cierra tocando fuera, con la X y volviendo a tocar Menú.
|
||||
- Con la hoja abierta, atrás cambiaba de página. Ahora la cierra.
|
||||
- Al volver atrás desde un módulo abierto desde la hoja, la portada salía con la hoja
|
||||
abierta (la entrada del historial de la hoja seguía ahí). Ahora se quita antes de
|
||||
ir al módulo: atrás lleva a la portada, cerrada.
|
||||
|
||||
## Tamaños y sistemas (19/10/2026)
|
||||
|
||||
| Dónde | Motor | Resultado |
|
||||
|---|---|---|
|
||||
| Pixel 9, Android 16, 412 dp, gestos y tres botones | WebView actual | Bien. APK real |
|
||||
| 360 × 540 dp | WebView actual | La hoja se desliza; todo se alcanza |
|
||||
| Tableta 800 dp | WebView actual | Contenido y hoja centrados a 720 y 640 px |
|
||||
| Letra al 130 % | WebView actual | Bien |
|
||||
| Pixel 3a, Android 9, 412 dp | WebView 69 | Tras los arreglos, bien (ver abajo) |
|
||||
| 360 dp, Android 11 | Chrome/WebView 83 | Bien: 9 pantallas sin desbordar, 5 pestañas de 70 dp |
|
||||
|
||||
En Android 9 y 11 de emulador la APK no arranca: el node del dev es arm64 y el
|
||||
traductor ARM de las imágenes x86 se para con una instrucción que no conoce (SIGILL).
|
||||
Pasa igual con la APK oficial, así que no es de la rama. Se probó el motor con el
|
||||
servidor local.
|
||||
|
||||
Lo que fallaba en WebView 69 y está arreglado:
|
||||
|
||||
- Fichas y filtros pegados unos a otros (no hay `gap` en flex): márgenes.
|
||||
- La hoja del menú no se veía (no hay `inset` ni `dvh`): top/left/right/bottom y `vh`.
|
||||
- Los grupos pequeños del menú se montaban (no hay `:has`): de dos en dos solo donde
|
||||
hay `:has`; en el viejo, cada grupo a lo ancho.
|
||||
- El botón No molestar quedaba encima de la hoja: ahora va por debajo de ella.
|
||||
|
||||
## Qué mirar en la prueba
|
||||
|
||||
- Desde la portada, cuántos toques hacen falta para llegar a Tribus, a Votaciones y a
|
||||
Ajustes, y desde dentro de un foro.
|
||||
- Si alguien que no conoce la app encuentra el menú sin que se lo digan.
|
||||
- Si al volver atrás la hoja se queda abierta (no debería).
|
||||
- Que la barra de cinco quepa a 360 px de ancho.
|
||||
|
|
@ -1,163 +0,0 @@
|
|||
# Frontera con upstream: que se puede tocar y que no
|
||||
|
||||
Las normas del dia a dia estan en [GUIA_DE_DESARROLLO.md](../GUIA_DE_DESARROLLO.md); esto
|
||||
es la medicion de la que salen.
|
||||
|
||||
Medido el 19/10/2026 sobre el repositorio del dev (code.03c8.net/krakenslab/oasis),
|
||||
de la release 1.0.1 (29/08) a la 1.3.3 (18/10), y comparado con la rama PRUEBAS.
|
||||
Los scripts estan en el historial de la sesion; para repetirlo basta clonar upstream y
|
||||
contar con `git log --numstat` por fichero.
|
||||
|
||||
## 1. Como trabaja el dev
|
||||
|
||||
- Una release casi cada dia: 39 entre la 1.0.1 y la 1.3.3, en siete semanas.
|
||||
Cada una es un solo commit ("Oasis release X.Y.Z"), sin historia intermedia: no
|
||||
hay forma de coger un cambio suelto, siempre llega todo junto.
|
||||
- El backend crece sin parar: `backend.js` paso de ~9.900 a ~15.400 lineas y se
|
||||
toca en 34 de 39 releases. La cadena de middleware (el array `middleware` del final)
|
||||
ha ganado capas en este tiempo: contexto por peticion (`requestScope`), idioma de
|
||||
primera ejecucion, CSP, etc.
|
||||
- Ya piensa en el movil: tiene su propio tema `OasisMobile.css`, usa la misma
|
||||
bandera `process.env.OASIS_MOBILE` que la rama y tiene workflows `activists` y
|
||||
`mobile`. Va en nuestra direccion; cuanto mas nos pisemos con el, peor.
|
||||
- Las clases CSS cambian, pero poco a poco: de la 1.0.1 a la 1.2.0 aparecen 335
|
||||
clases y desaparecen 98; hasta la 1.3.1, +304 / -94; de la 1.3.1 a la 1.3.3, +47 / -8.
|
||||
Unas 100 clases muertas cada tres semanas, sobre ~2.200: un 5 %.
|
||||
|
||||
Ficheros que mas toca (releases que lo tocan de 39 / lineas movidas):
|
||||
|
||||
| Fichero | Releases | Lineas |
|
||||
|---|---|---|
|
||||
| `src/backend/backend.js` | 34 | 11.621 |
|
||||
| `src/server/package.json` + lock | 34 | 22.700 |
|
||||
| `src/client/assets/styles/style.css` | 32 | 2.522 |
|
||||
| `translations/oasis_*.js` (los 11) | 31 cada uno | ~2.000 cada uno |
|
||||
| `src/views/main_views.js` | 31 | 2.634 |
|
||||
| `src/views/activity_view.js` | 27 | 1.183 |
|
||||
| `src/views/tribes_view.js` | 23 | 2.675 |
|
||||
| `src/client/assets/themes/OasisMobile.css` | 21 | 3.040 |
|
||||
| `src/views/settings_view.js` | 17 | 849 |
|
||||
| `src/views/vote_view.js` | 14 | 155 |
|
||||
| `src/configs/config-manager.js` | 11 | 120 |
|
||||
| `src/client/assets/styles/mobile.css` | 10 | 514 |
|
||||
| `src/views/parliament_view.js` | 10 | 282 |
|
||||
| `src/models/workflows_model.js` | 8 | 133 |
|
||||
| `src/views/welcome_view.js` | 8 | 440 |
|
||||
| `src/server/SSB_server.js` | 7 | 183 |
|
||||
| `src/models/votes_model.js` | 4 | 33 |
|
||||
| `src/backend/request_guards.js` (CSP) | 4 | 111 |
|
||||
|
||||
## 2. El mapa
|
||||
|
||||
### Rojo: no se toca
|
||||
|
||||
Cada linea nuestra aqui es un conflicto casi seguro en la siguiente release, y ademas
|
||||
cambia el comportamiento de la red, no solo como se ve.
|
||||
|
||||
- `src/backend/backend.js`: rutas y, sobre todo, la cadena de `middleware`.
|
||||
- `src/models/*` de upstream: lo que se publica en el log SSB y como se cuenta. Si dos
|
||||
nodos cuentan distinto (votos, tribus, gobierno), la red se rompe. Ejemplo: "quien
|
||||
crea una votacion no vota" es una regla de `votes_model.js`; cambiarla en la rama
|
||||
haria que nuestro movil y un escritorio dieran resultados distintos.
|
||||
- `src/server/*` (arranque de SSB, `ssb_config`, `package.json` y dependencias).
|
||||
- `src/backend/request_guards.js` (CSP: no hay estilos ni scripts en linea, y asi
|
||||
debe seguir).
|
||||
- `translations/oasis_*.js`: se reescriben en 31 de 39 releases.
|
||||
- `src/client/assets/styles/style.css` y `mobile.css`: son del dev.
|
||||
- `src/client/assets/themes/*.css` de upstream, incluido `OasisMobile.css`.
|
||||
|
||||
### Ambar: solo un gancho de una linea, con la bandera de movil
|
||||
|
||||
Las vistas de upstream (`main_views.js`, `tribes_view.js`, `vote_view.js`,
|
||||
`settings_view.js`...). Se reescriben a menudo, asi que lo que la rama necesite ahi va
|
||||
como una llamada a una funcion nuestra, envuelta en
|
||||
`process.env.OASIS_MOBILE === '1'`, y la logica vive en un fichero propio. Si el dev
|
||||
reescribe la vista, se vuelve a poner una linea, no se reconstruye una funcion.
|
||||
|
||||
### Verde: nuestro, sin limite
|
||||
|
||||
- Hojas propias: `themes/Acampada.css`, `styles/mobile_base.css`,
|
||||
`styles/hive_acampada.css`, `styles/mobile_ux.css`.
|
||||
- Vistas propias: `hive_views.js`, `mobile_icons.js`, `identities_view.js` y las
|
||||
`mobile_*.js`.
|
||||
- El router propio `src/backend/fork_routes.js`: va justo antes del de upstream en la
|
||||
cadena y ve todas las peticiones. Puede atender rutas nuevas, o envolver una ruta de
|
||||
upstream (dejarla hacer y retocar la respuesta) sin editarla.
|
||||
- Traducciones propias en `i18n_fork.js` (se mezclan encima de las de upstream).
|
||||
- Modelo propio `fork_workflows.js`.
|
||||
- Todo `android/`.
|
||||
|
||||
## 3. Como esta la rama hoy
|
||||
|
||||
43 ficheros de upstream modificados y 22 propios. Lo que mas pesa:
|
||||
|
||||
| Fichero de upstream | Lineas distintas | Riesgo |
|
||||
|---|---|---|
|
||||
| `themes/OasisMobile.css` | 4.873 | Alto: es casi otro fichero. El dev lo toca en 21/39 releases y cada vez hay que elegir entre su version y la nuestra. |
|
||||
| `views/main_views.js` | 173 | Alto (31/39) |
|
||||
| `backend/backend.js` | 133 (21 trozos) | Alto (34/39) |
|
||||
| `configs/oasis-config.json` | 135 | Medio: es la config de serie del movil |
|
||||
| `backend/blobHandler.js` | 190 | Medio (limpieza de fotos) |
|
||||
| `backend/updater.js` | 133 | Bajo (3/39) |
|
||||
| `views/modules_view.js` | 91 | Bajo (5/39) |
|
||||
| `views/parliament_view.js`, `settings_view.js`, `vote_view.js`, `tribes_view.js` | 50-75 cada una | Medio-alto |
|
||||
| `models/workflows_model.js` | 31 | Medio: redefine el workflow `activists` del dev |
|
||||
| `server/ssb_config.js`, `SSB_server.js` | 40 | Medio (multicuenta) |
|
||||
|
||||
Las hojas propias y `OasisMobile.css` llevan selectores a clases que ya no pinta nadie:
|
||||
61 en `OasisMobile.css`, 29 en `hive_acampada.css`, 5 en `Acampada.css`. No rompen
|
||||
nada, pero son peso muerto y confunden.
|
||||
|
||||
## 4. Reglas para cada cambio
|
||||
|
||||
1. Primero CSS. Si se arregla con una hoja propia, se arregla ahi.
|
||||
2. Si hace falta HTML distinto: funcion en un fichero propio + una linea en la vista
|
||||
de upstream, con la bandera de movil.
|
||||
3. Si hace falta una ruta o retocar una respuesta: en el router propio. Nunca una
|
||||
ruta nueva ni un middleware en `backend.js`.
|
||||
4. Nunca cambiar lo que se publica ni como se cuenta (modelos de upstream).
|
||||
5. Textos nuevos en `i18n_fork.js`, en los 11 idiomas.
|
||||
6. Nada en linea: la CSP no deja `style=""` ni `<script>` en la pagina.
|
||||
7. Antes de integrar una release, repetir esta medicion y mirar si alguna clase de la
|
||||
que dependemos ha desaparecido.
|
||||
|
||||
## 5. Plan para sanear la rama
|
||||
|
||||
- Fase A (hecha el 19/10): lo del 19/10 que se metio en ficheros del dev sale a
|
||||
ficheros propios. Ver la seccion 6.
|
||||
- Fase B (hecha el 19/10): `OasisMobile.css` vuelve a ser la del dev, identica a la
|
||||
1.3.3. Nuestra maqueta esta en `styles/mobile_base.css`, que el movil carga siempre
|
||||
antes del skin. Con el skin OasisMobile se ve el tema del dev encima de la maqueta.
|
||||
- Fase C (hecha el 19/10): de los 20 trozos de `backend.js` quedan 9 (21 lineas).
|
||||
El resto (rutas cortadas en el movil, modulos que no van, avatar, lanzador,
|
||||
retoques del HTML, descargas de la copia de seguridad, cambio de tema) esta en
|
||||
`backend/mobile_layer.js` y en `fork_routes.js`. Los que quedan no se pueden
|
||||
hacer desde fuera: expresiones regulares para Node 18, no abrir el navegador, fotos
|
||||
duplicadas, modo chats y el montaje del router.
|
||||
- Fase D (hecha el 19/10): `activists` vuelve a ser el del dev. Karvan y el workflow
|
||||
`acampada` se registran desde `models/fork_workflows.js`. La config de serie del
|
||||
movil coincide con `acampada`. Queda en `workflows_model.js` la logica de skins del
|
||||
movil.
|
||||
- Correccion (19/10, noche): para el movil la referencia buena es el nodejs-project de
|
||||
la APK oficial, no el git del dev. Parte de lo que la fase C saco de backend.js era
|
||||
codigo de movil del propio dev; backend.js vuelve a ser su version de movil mas dos
|
||||
lineas (montar nuestro router), y modules_view.js su version de movil. Contra la APK
|
||||
oficial la rama cambia 17 ficheros y 561 lineas; la lista esta en la guia.
|
||||
- Fuera Karvan (19/10): el dev ya trae Telefono y Salas, asi que se quita el modulo
|
||||
propio de salas y llamadas (modelo, vista, JS, CSS, router, pruebas, textos).
|
||||
`Clear-SNH.css` y `modules_view.js` vuelven a ser identicos a los del dev y la linea
|
||||
de `SSB_server.js` que enchufaba el audio de Android pasa a `main.js`, usando los
|
||||
`setAudioFactory`, `setVideoFactory` y `setStreamFactory` que el propio dev exporta.
|
||||
- Fase E (hecha el 19/10): fuera 153 selectores muertos de `mobile_base.css`,
|
||||
`hive_acampada.css` y `Acampada.css` (287 lineas). Comparado pixel a pixel en 14
|
||||
pantallas: identicas.
|
||||
|
||||
## 6. Fase A: lo que se ha movido
|
||||
|
||||
| Antes (en ficheros del dev) | Ahora |
|
||||
|---|---|
|
||||
| `backend.js`: `checkMod` envuelto para recordar la seccion apagada | El router propio mira las redirecciones a `/modules` de vuelta |
|
||||
| `backend.js`: ruta `POST /modules/enable` y `?saved=1` tras guardar | Router propio |
|
||||
| `modules_view.js`: vista de Modulos del movil | `views/mobile_modules_view.js`, servida por el router propio |
|
||||
| `workflows_model.js`: cuatro modulos mas en `activists` | Workflow propio `acampada` registrado desde `models/fork_workflows.js` |
|
||||
| `vote_view.js`, `tribes_view.js`, `settings_view.js`, `main_views.js`, `welcome_view.js`: funciones nuevas | Funciones en `views/mobile_ui.js`; en la vista de upstream queda la llamada |
|
||||
|
|
@ -1,254 +0,0 @@
|
|||
Prueba de usabilidad - Oasis Mobile 1.3.3 (rama PRUEBAS, tema Acampada)
|
||||
19/10/2026 - emulador Pixel 9, Android 16, 1080x2424; tambien 720x1280 y letra al 130 %
|
||||
Escenario: alguien de la acampada que entra, cambia a castellano, activa tribus
|
||||
y parlamento, crea tribus, vota y abre una sala de voz.
|
||||
|
||||
=================================
|
||||
Botones complejos de interpretar
|
||||
=================================
|
||||
|
||||
- Los iconos del lanzador son letras del alfabeto vai (ꔙ ꕿ ꖒ ꕒ ꔗ...). No dicen
|
||||
nada de lo que hacen: hay que leer siempre la etiqueta de debajo.
|
||||
- Karvan (⧗) y Nodos (⧖) llevan casi el mismo reloj de arena.
|
||||
Videos lleva algo que parece un simbolo de dolar.
|
||||
- En la sala de voz los botones 🎤 🎥 ⛔ no tienen texto ni descripcion.
|
||||
⛔ no se sabe si es colgar, silenciar o salir.
|
||||
- En cada tarjeta de tribu el primer boton es una ✕ roja: es BORRAR TRIBU y
|
||||
borra con un solo toque, sin preguntar. Probado: la tribu desaparece.
|
||||
- En "Crear tribu" el texto CREAR TRIBU sale tres veces (boton de arriba,
|
||||
titulo y boton de enviar). El de arriba recarga la pagina y borra lo escrito.
|
||||
- Dos campos seguidos se llaman "Ubicacion" (lugar y enlace de mapa).
|
||||
- "Estado: Privada/Publica" y "Modo: Estricto/Abierta" no explican que
|
||||
significan. Por defecto sale Privada + Estricto.
|
||||
- Votacion: las opciones salen mezcladas en ingles y castellano
|
||||
(YES, NO, ABSTENTION, CONFUNDIDO, SEGUIR MAYORIA, SIN INTERES), partidas por
|
||||
silabas y la tabla se corta por la derecha.
|
||||
- Jerga a la vista: "server relay", "Synced-peers", "HcT/HcH", "TASA ECO",
|
||||
la clave @...ed25519 completa en el pie y en el buzon.
|
||||
|
||||
=================================
|
||||
Inicio intuitivo "empezar"
|
||||
=================================
|
||||
|
||||
- El aviso "Tu avatar aun no esta configurado - EMPEZAR" sale arriba en TODAS
|
||||
las paginas. En pantalla pequena se come casi una quinta parte. EMPEZAR no
|
||||
dice que va a pasar (es poner foto y nombre).
|
||||
- En Bienvenida el primer boton, el mas grande, es "AHORA NO". Lo facil es
|
||||
saltarse todo.
|
||||
- El nombre viene relleno con un trozo de la clave (jlYELyx4). Parece un nombre
|
||||
ya puesto y no lo es.
|
||||
- El idioma sale en el del movil la primera vez (el emulador estaba en ingles).
|
||||
Cambiarlo despues costaba: Ajustes, bajar a la segunda pantalla, desplegable,
|
||||
Espanol y boton "Establecer idioma".
|
||||
- La pantalla de carga si sale en castellano y tarda unos 10 s: bien.
|
||||
- Ajustes mide unas 7 pantallas. Lo que mas se toca (idioma, aspecto) queda
|
||||
debajo de Identidades, que es lo mas tecnico.
|
||||
- Al abrir una seccion apagada (p. ej. Tribus) te manda sin avisar a la tabla
|
||||
de Modulos. Deberia decir "Tribus esta apagado - Activar".
|
||||
- Invitar a una sala pide el @feed-id de la otra persona. Nadie lo sabe de
|
||||
memoria: hace falta elegir de los contactos o compartir un enlace / QR.
|
||||
|
||||
=================================
|
||||
Reduccion de funcionalidades
|
||||
"skin activistas"
|
||||
=================================
|
||||
|
||||
- El flujo "activistas" NO activa tribus, parlamento ni votaciones. Para una
|
||||
acampada con 12 tribus es justo lo que falta. Propuesta: anadir tribes,
|
||||
parliament y votes al flujo activistas (o hacer un flujo "acampada").
|
||||
- Activar un modulo a mano: la pagina Modulos son 52 filas, unas 11 pantallas.
|
||||
Tribus esta a 7800 px de altura y la casilla mide 16x16 px (lo minimo
|
||||
recomendable son 48). El boton Guardar esta al final del todo.
|
||||
- Propuesta: en movil, Modulos como interruptores grandes agrupados
|
||||
(Hablar / Organizarse / Compartir) y guardado al tocar.
|
||||
- Cada tribu que creas te manda a ti mismo un mensaje del POLITICALBOT
|
||||
("Ha comenzado un nuevo ciclo de gobierno... Anarchy"). 12 tribus = 12 avisos
|
||||
en el buzon. En movil sobra o deberia agruparse.
|
||||
|
||||
=================================
|
||||
Voz y turnos de palabra
|
||||
=================================
|
||||
|
||||
- LLAMAR en Karvan pide primero la CAMARA aunque solo quieras hablar. Si dices
|
||||
que no a la camara la llamada no arranca, aunque des el micro. Sale el aviso
|
||||
"pendiente del wrapper", que es una nota interna.
|
||||
Arreglo sencillo: LLAMAR empieza solo con audio y 🎥 anade video.
|
||||
(src/client/public/js/karvan.js, funcion startCall)
|
||||
- Solo el boton 🎤 suelto funciona con audio.
|
||||
- La llamada de Karvan se corta al cambiar de pagina (probado: el micro se para
|
||||
al entrar en Parlamento). La sala que sigue viva al navegar es la de Mumble,
|
||||
que va en otra app.
|
||||
- Propuesta: en Parlamento y en cada tribu, boton "Entrar a la sala de voz" que
|
||||
abra mumble://mumble.acampada26s.net/<canal de la tribu>.
|
||||
- Turnos de palabra con tiempo: no existe. Encaja como cola de "pido palabra"
|
||||
dentro de la sala (lista + cronometro visible para todas).
|
||||
|
||||
=================================
|
||||
Listado de tribus
|
||||
=================================
|
||||
|
||||
- Confirmado: cada tribu ocupa casi una pantalla porque todas repiten la misma
|
||||
imagen generica grande. 12 tribus = 10 pantallas de scroll.
|
||||
- Propuesta: lista compacta (nombre + miembros + publica/privada, una linea por
|
||||
tribu), imagen solo si la tribu tiene una propia, y el buscador arriba fijo.
|
||||
|
||||
=================================
|
||||
Votaciones: semaforo / termometro
|
||||
=================================
|
||||
|
||||
- El servidor ya acepta opciones propias (campo "options" separado por comas),
|
||||
pero el formulario no lo ofrece: siempre salen las 6 opciones fijas.
|
||||
- Propuesta: al crear, elegir tipo: Si/No - Semaforo (verde, amarillo, rojo) -
|
||||
Termometro (1 a 5). Sale casi gratis.
|
||||
- Quien crea la votacion no puede votar en ella y la pantalla no lo explica.
|
||||
En una asamblea, quien facilita se queda sin voto.
|
||||
- El selector de fecha es el nativo de Android y propone una semana: bien.
|
||||
|
||||
=================================
|
||||
Lo que va bien
|
||||
=================================
|
||||
|
||||
- Sin desbordes horizontales en 1080 ni en 720 de ancho (salvo tablas).
|
||||
- Tema Acampada legible, botones grandes, buen contraste.
|
||||
- La barra de abajo (Actividad, Chats, Publicar, Buzon) se entiende sola.
|
||||
- Actualizar encima de la 0.9.5 no pierde la identidad ni los datos.
|
||||
|
||||
Pendiente de probar: primer arranque en limpio (instalacion nueva), dos moviles
|
||||
en la misma sala, y la barra de abajo que tapa media pantalla con el teclado
|
||||
abierto (visto en Crear tribu).
|
||||
|
||||
=================================
|
||||
Arreglado en PRUEBAS (19/10/2026)
|
||||
=================================
|
||||
|
||||
- Borrar pide confirmacion ("¿Borrar para siempre? Si, borrar") y la X va la
|
||||
ultima de la fila. Vale para tribus y para todo el contenido.
|
||||
- Karvan: Llamar empieza solo con voz y la camara se anade con su boton.
|
||||
Botones con icono y nombre (Llamar, Micro, Camara, Colgar). Sin el recuadro
|
||||
gris en llamadas de voz. Ayuda para invitar. Textos en los 11 idiomas.
|
||||
- Votaciones: al crear se elige Si/No, Semaforo (verde, amarillo, rojo) o
|
||||
Termometro (1 a 5). Resultados en barras. Aviso a quien la crea.
|
||||
- Flujo activistas con tribus, parlamento, votaciones y eventos.
|
||||
- Abrir una seccion apagada dice cual es y ofrece "Activar" de un toque.
|
||||
- Modulos: agrupados como el lanzador, interruptores grandes, Guardar fijo.
|
||||
- Tribus en filas compactas (12 tribus: de 10 pantallas a unas 3). Dentro de
|
||||
una tribu, las 25 secciones en una tira de fichas + "Mas".
|
||||
- Iconos del lanzador y de la barra: dibujos claros en vez de letras vai.
|
||||
La barra de abajo marca la seccion abierta y se esconde con el teclado.
|
||||
- Ajustes empieza por idioma y aspecto; los idiomas se eligen de un toque.
|
||||
- Aviso del avatar solo en la portada y en una linea. "Ahora no" al final de
|
||||
la bienvenida.
|
||||
- Pie de una linea: "Aun no sincronizas con nadie" / "Sincronizando con N".
|
||||
- Fuera la jerga: tasa ECO, HcT/HcH, "server relay".
|
||||
- Espacio: un solo margen de 14px (las fichas perdian 74px por lado).
|
||||
Letra: Anton solo en titulos y en el boton principal.
|
||||
|
||||
=================================
|
||||
Sigue pendiente
|
||||
=================================
|
||||
|
||||
- Invitar a una sala o mandar un privado eligiendo de los contactos, no
|
||||
pegando el @id.
|
||||
- Turnos de palabra con tiempo en la sala de voz.
|
||||
- Boton "Entrar a la sala de voz" (Mumble) en Parlamento y en cada tribu.
|
||||
- Quien crea una votacion sigue sin poder votar (regla de upstream: cambiarla
|
||||
haria que los nodos contaran distinto).
|
||||
- Probar un primer arranque en limpio y dos moviles en la misma sala.
|
||||
|
||||
=================================
|
||||
Segunda ronda (19/10/2026, tarde)
|
||||
=================================
|
||||
|
||||
Desde ahora cada arreglo respeta docs/FRONTERA_UPSTREAM.md: primero CSS propio;
|
||||
si hace falta HTML, una funcion propia y una linea en la vista del dev; nada en
|
||||
backend, middleware ni modelos del dev.
|
||||
|
||||
- Boton principal de cada modulo (Crear chat, foro, pad, feed, evento...): el
|
||||
mismo en todos. Antes unos a todo lo ancho, otros estrechos, otros con otra
|
||||
letra. [CSS]
|
||||
- Listas vacias: icono, texto centrado y recuadro discontinuo, en vez de una
|
||||
linea suelta ("No se encontraron chats."). [CSS]
|
||||
- El pie se queda abajo aunque la pagina sea corta. [CSS]
|
||||
- Buzon: cada mensaje en pocas lineas (remitente y fecha arriba, el aviso, el
|
||||
titulo y tres lineas de texto). Fuera Para (eres tu), Tasa ECO y Cifrado, que
|
||||
eran iguales en todos. Antes un aviso ocupaba una pantalla. [CSS]
|
||||
- Busqueda: primero el campo de buscar; las fechas y el ID despues. [CSS]
|
||||
- Nodos: Refrescar, Quitar inactivos y Exportar en rejilla. [CSS]
|
||||
- Workflow propio "Acampada" (chats, Karvan, pads, foros, wiki, tribus,
|
||||
parlamento, votaciones, eventos...) en Ajustes y en la bienvenida, sin tocar
|
||||
el "Activismo" del dev. [fichero propio]
|
||||
|
||||
Visto y pendiente de esta ronda:
|
||||
- Buzon: Marcar como leido / Archivar / Borrar siguen uno debajo de otro.
|
||||
- Buzon: los avisos del PoliticalBot de las tribus propias siguen llegando
|
||||
(es upstream; se puede agrupar en la vista, no quitar).
|
||||
- Los ID @...ed25519 siguen a la vista en perfil, buzon y nodos: falta mostrar
|
||||
el nombre y dejar el ID para "copiar".
|
||||
|
||||
=================================
|
||||
Tercera ronda: emulador limpio (19/10/2026, noche)
|
||||
=================================
|
||||
|
||||
Emulador nuevo (Pixel 9, Android 16, 12 GB) para probar una instalacion desde
|
||||
cero con la APK sin Karvan (codigo 102).
|
||||
|
||||
- Primer arranque: 18 segundos hasta la portada. Bien.
|
||||
- Quien entra por primera vez no ve por donde empezar: el lanzador ocupa toda la
|
||||
pantalla y el aviso "pon tu nombre y tu foto" quedaba a 1.300 px, abajo del
|
||||
todo. Arreglado: el aviso va encima del lanzador. [CSS]
|
||||
- El boton No molestar del Telefono ocupaba una fila entera arriba de cada
|
||||
pagina. Arreglado: va en la barra de arriba, junto al avatar. [CSS]
|
||||
- Ajustes marcaba English aunque se hubiera elegido otro idioma. Arreglado. [vista propia]
|
||||
- LLAMADAS: en esta APK el Telefono dice "las llamadas no estan disponibles". La
|
||||
APK oficial del dev trae un puente nativo de audio y camara (.audio_bridge) y
|
||||
la nuestra no. Hay que escribirlo en la app Android o construir sobre la APK
|
||||
oficial. Mientras tanto, en la rama no hay llamadas.
|
||||
- El avatar por defecto (una calavera con gafas) asusta un poco para quien
|
||||
empieza. Es del dev; se puede cambiar solo en el skin.
|
||||
|
||||
Con la APK montada sobre la oficial (1.3.3-hacklab, mismo emulador):
|
||||
- Llamadas: ahora SI. El Telefono ofrece "Nueva llamada" y en una sala el micro
|
||||
graba de verdad (comprobado en Android: VOICE_COMMUNICATION activo) y se corta
|
||||
al salir.
|
||||
- Turnos de palabra: las Salas del dev ya tienen "Levantar la mano". Es la base de
|
||||
lo que pedia el pad; falta el cronometro.
|
||||
- Dentro de una sala, los controles (silenciar, camara, levantar la mano, salir)
|
||||
quedan muy abajo, debajo de la ficha de la sala. Habria que tenerlos a mano
|
||||
mientras se esta dentro. [pendiente, CSS]
|
||||
- La app del dev pide el permiso de camara nada mas abrirse, antes de ensenar
|
||||
nada. Es su app; no se toca.
|
||||
- El boton de crear sala se tapa con el teclado al escribir el titulo: hay que
|
||||
cerrar el teclado para verlo.
|
||||
|
||||
=================================
|
||||
Cuarta ronda: tamaños y sistemas (19/10/2026)
|
||||
=================================
|
||||
|
||||
Con la navegación nueva (portada = actividad, pestaña Menú abajo).
|
||||
|
||||
- Android 16, Pixel 9 (APK real): menú, atrás con gesto y con tres botones, ir a un
|
||||
módulo desde la hoja y volver. Bien.
|
||||
- Fallaba: volver atrás desde un módulo abierto desde la hoja dejaba la portada con
|
||||
la hoja abierta. Arreglado. [script propio]
|
||||
- Android 9 (WebView 69, el que trae sin actualizar): fichas pegadas, hoja del menú
|
||||
sin pintar, grupos montados y No molestar encima de la hoja. Arreglado. [CSS]
|
||||
- Android 11 a 360 dp (motor 83): nueve pantallas sin desbordar, sin textos de
|
||||
menos de 12 px ni botones de menos de 40 px. La barra de cinco cabe (70 dp cada una).
|
||||
- En emulador, Android 9 y 11 no arrancan la APK (ni la nuestra ni la oficial): el
|
||||
node arm64 no corre en el traductor ARM de x86. En un móvil de verdad no pasa; falta
|
||||
probarlo en uno físico con Android 9-11.
|
||||
- Tableta y letra al 130 %: bien.
|
||||
- Bienvenida, paso Tipo: había que elegir una tarjeta y bajar seis tarjetas grandes
|
||||
hasta Guardar. Ahora cada opción es un botón: un toque y queda guardado, el paso se
|
||||
marca y se abre el siguiente. Una fila por opción con miniatura (en rejilla de tres,
|
||||
"Conversaciones" se partía a 360 px). En Ajustes, lo mismo, y la sección va justo
|
||||
después del idioma para encontrarla fácil. [vista propia + una línea en welcome_view
|
||||
y settings_view]
|
||||
- Bienvenida: faltaba elegir para qué se usa Oasis (el backend del dev quita ese paso
|
||||
en el móvil). Vuelve como paso "Perfil", el segundo, justo después de la identidad:
|
||||
Activismo primero, luego Acampada y el resto, cada uno con una línea de para qué
|
||||
sirve. Un toque y guardado. En Ajustes, la misma lista. [vista propia + una línea
|
||||
en welcome_view y settings_view]
|
||||
- Con el perfil Activismo (45 módulos) los botones Personal/Social de la barra de
|
||||
arriba no cabían a 360 px y tapaban el logo. Ya no hacían falta (filtraban el
|
||||
lanzador, que ahora está en la hoja del menú): quitados. [vista propia]
|
||||
|
Before Width: | Height: | Size: 305 KiB |
|
Before Width: | Height: | Size: 134 KiB |
|
Before Width: | Height: | Size: 160 KiB |
|
Before Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 133 KiB |
|
Before Width: | Height: | Size: 110 KiB |
|
Before Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 54 KiB |
|
|
@ -1,208 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Portada y esquemas del README, con la paleta del tema Acampada.
|
||||
|
||||
Las capturas de movil (01..08) se hacen aparte, con la app arrancada en un nodo de
|
||||
pruebas a 412x915 y escala 2; este script compone 00-portada.png a partir de ellas y
|
||||
dibuja esquema-interfaz.png, esquema-rama.png y esquema-llamadas.png.
|
||||
El texto vive aqui: se cambia y se relanza, nunca se retocan los PNG a mano.
|
||||
|
||||
python3 docs/capturas/generar_portada.py
|
||||
"""
|
||||
import math
|
||||
from pathlib import Path
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
ANTON = HERE.parents[1] / "src/client/assets/fonts/Anton-Regular.ttf"
|
||||
SANS = "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"
|
||||
|
||||
BG, FG, NEON, MUTED = "#ffffff", "#000000", "#00e5ff", "#555555"
|
||||
|
||||
|
||||
def font(path, size):
|
||||
return ImageFont.truetype(str(path), size)
|
||||
|
||||
|
||||
def phone(shot, width):
|
||||
"""Una captura dentro de un marco negro grueso con la sombra cian de los botones."""
|
||||
im = Image.open(HERE / shot).convert("RGB")
|
||||
h = round(im.height * width / im.width)
|
||||
im = im.resize((width, h), Image.LANCZOS)
|
||||
pad, radius, shadow = 10, 34, 10
|
||||
frame = Image.new("RGBA", (width + 2 * pad, h + 2 * pad + shadow), (0, 0, 0, 0))
|
||||
d = ImageDraw.Draw(frame)
|
||||
d.rounded_rectangle((0, shadow, width + 2 * pad - 1, h + 2 * pad + shadow - 1), radius, fill=NEON)
|
||||
d.rounded_rectangle((0, 0, width + 2 * pad - 1, h + 2 * pad - 1), radius, fill=FG)
|
||||
mask = Image.new("L", im.size, 0)
|
||||
ImageDraw.Draw(mask).rounded_rectangle((0, 0, width - 1, h - 1), radius - pad, fill=255)
|
||||
frame.paste(im, (pad, pad), mask)
|
||||
return frame
|
||||
|
||||
|
||||
def portada():
|
||||
W, H = 1600, 1100
|
||||
out = Image.new("RGB", (W, H), BG)
|
||||
d = ImageDraw.Draw(out)
|
||||
d.text((80, 40), "OASIS MOBILE", font=font(ANTON, 92), fill=FG)
|
||||
bottom = d.textbbox((80, 40), "OASIS MOBILE", font=font(ANTON, 92))[3]
|
||||
d.rectangle((80, bottom + 12, 420, bottom + 20), fill=FG)
|
||||
d.text((80, 196), "Rama PRUEBAS del hacklab · base Oasis 1.3.3 · tema Acampada, pensado para el pulgar",
|
||||
font=font(SANS, 27), fill=MUTED)
|
||||
shots = [("01-portada.png", "Actividad al abrir"), ("02-tribus.png", "Tribus en filas"),
|
||||
("03-votacion.png", "Votar en semáforo"), ("07-oasismobile.png", "El menú, tema del dev")]
|
||||
pw, gap, top = 320, 50, 270
|
||||
x = (W - (4 * (pw + 20) + 3 * gap)) // 2
|
||||
cap = font(ANTON, 34)
|
||||
for shot, label in shots:
|
||||
fr = phone(shot, pw)
|
||||
out.paste(fr, (x, top), fr)
|
||||
tw = d.textlength(label.upper(), font=cap)
|
||||
d.text((x + (fr.width - tw) / 2, top + fr.height + 18), label.upper(), font=cap, fill=FG)
|
||||
x += fr.width + gap
|
||||
out.save(HERE / "00-portada.png", optimize=True)
|
||||
|
||||
|
||||
def caja(d, box, titulo, lineas, relleno=BG):
|
||||
x0, y0, x1, y1 = box
|
||||
d.rounded_rectangle((x0, y0 + 6, x1, y1 + 6), 18, fill=FG)
|
||||
d.rounded_rectangle(box, 18, fill=relleno, outline=FG, width=4)
|
||||
t = font(ANTON, 30)
|
||||
d.text(((x0 + x1 - d.textlength(titulo, font=t)) / 2, y0 + 18), titulo, font=t, fill=FG)
|
||||
s = font(SANS, 18)
|
||||
for i, l in enumerate(lineas):
|
||||
d.text(((x0 + x1 - d.textlength(l, font=s)) / 2, y0 + 70 + i * 26), l, font=s, fill=FG)
|
||||
|
||||
|
||||
def esquema():
|
||||
W, H = 1400, 840
|
||||
out = Image.new("RGB", (W, H), BG)
|
||||
d = ImageDraw.Draw(out)
|
||||
d.text((46, 26), "LA INTERFAZ DE MÓVIL, POR DENTRO", font=font(ANTON, 46), fill=FG)
|
||||
bottom = d.textbbox((46, 26), "LA INTERFAZ DE MÓVIL, POR DENTRO", font=font(ANTON, 46))[3]
|
||||
d.rectangle((46, bottom + 10, 260, bottom + 16), fill=FG)
|
||||
d.text((46, bottom + 30), "Casi sesenta módulos no caben en una pantalla de teléfono. Lo primero es lo que pasa; el resto, en el menú.",
|
||||
font=font(SANS, 18), fill=MUTED)
|
||||
|
||||
# el movil esquematico
|
||||
px0, py0, px1, py1 = 110, 170, 470, 790
|
||||
d.rounded_rectangle((px0, py0, px1, py1), 30, outline=FG, width=5)
|
||||
d.rounded_rectangle((px0 + 16, py0 + 20, px1 - 16, py0 + 84), 14, fill=BG, outline=FG, width=3)
|
||||
s = font(SANS, 15)
|
||||
d.text((px0 + 46, py0 + 42), "logo · no molestar · avatar", font=s, fill=FG)
|
||||
# en medio, la actividad: lo que ha pasado en la red
|
||||
y = py0 + 110
|
||||
d.text((px0 + 40, y), "ACTIVIDAD", font=font(ANTON, 22), fill=FG)
|
||||
d.line((px0 + 40, y + 32, px1 - 40, y + 32), fill=FG, width=3)
|
||||
y += 52
|
||||
for i in range(4):
|
||||
d.rounded_rectangle((px0 + 40, y, px1 - 40, y + 86), 12, fill=BG, outline=FG, width=3)
|
||||
d.rectangle((px0 + 56, y + 18, px0 + 150, y + 30), fill=FG)
|
||||
d.rectangle((px0 + 56, y + 44, px1 - 70, y + 52), fill=MUTED)
|
||||
d.rectangle((px0 + 56, y + 62, px1 - 120, y + 70), fill=MUTED)
|
||||
y += 104
|
||||
d.rounded_rectangle((px0 + 16, py1 - 84, px1 - 16, py1 - 20), 14, fill=NEON, outline=FG, width=3)
|
||||
d.text((px0 + 62, py1 - 62), "4 atajos que eliges tú · MENÚ", font=s, fill=FG)
|
||||
|
||||
bx0, bx1 = 560, 1320
|
||||
cajas = [
|
||||
((bx0, 190, bx1, 340), "ARRIBA — LA BARRA SUPERIOR",
|
||||
["El logo vuelve al inicio y el avatar abre tu perfil.",
|
||||
"En medio, el botón de No molestar del Teléfono.",
|
||||
"Con muchos módulos activos salen también los filtros Personal y Social."], BG),
|
||||
((bx0, 375, bx1, 565), "EN MEDIO — LA ACTIVIDAD",
|
||||
["Al abrir la app se ve lo que ha pasado en la red, no un menú.",
|
||||
"Dentro de cada módulo, los filtros van en fichas pequeñas",
|
||||
"y el botón principal (crear tribu, crear sala…) siempre igual."], BG),
|
||||
((bx0, 600, bx1, 750), "ABAJO — LOS ATAJOS Y EL MENÚ",
|
||||
["Cuatro atajos a tu elección y una quinta pestaña fija, Menú,",
|
||||
"que abre desde abajo una hoja con todos los módulos por secciones",
|
||||
"(Comunidad, Biblioteca, Gobernanza…). Está en todas las pantallas."], NEON),
|
||||
]
|
||||
flechas = [((px1 + 10, py0 + 52), (bx0 - 12, 262)), ((px1 + 10, 440), (bx0 - 12, 470)),
|
||||
((px1 + 10, py1 - 52), (bx0 - 12, 675))]
|
||||
for (a, b) in flechas:
|
||||
ang = math.atan2(b[1] - a[1], b[0] - a[0])
|
||||
tip = lambda da, r: (b[0] - r * math.cos(ang + da), b[1] - r * math.sin(ang + da))
|
||||
d.line((a, tip(0, 12)), fill=FG, width=4)
|
||||
d.polygon([b, tip(0.45, 20), tip(-0.45, 20)], fill=FG)
|
||||
for box, t, l, r in cajas:
|
||||
caja(d, box, t, l, r)
|
||||
out.save(HERE / "esquema-interfaz.png", optimize=True)
|
||||
|
||||
|
||||
def flecha(d, a, b, ancho=4):
|
||||
ang = math.atan2(b[1] - a[1], b[0] - a[0])
|
||||
tip = lambda da, r: (b[0] - r * math.cos(ang + da), b[1] - r * math.sin(ang + da))
|
||||
d.line((a, tip(0, 12)), fill=FG, width=ancho)
|
||||
d.polygon([b, tip(0.45, 20), tip(-0.45, 20)], fill=FG)
|
||||
|
||||
|
||||
def titulo(d, texto, sub):
|
||||
d.text((46, 26), texto, font=font(ANTON, 46), fill=FG)
|
||||
bottom = d.textbbox((46, 26), texto, font=font(ANTON, 46))[3]
|
||||
d.rectangle((46, bottom + 10, 260, bottom + 16), fill=FG)
|
||||
d.text((46, bottom + 30), sub, font=font(SANS, 18), fill=MUTED)
|
||||
|
||||
|
||||
def esquema_rama():
|
||||
"""Como va la rama encima de Oasis: lo del dev abajo, lo nuestro encima."""
|
||||
W, H = 1400, 760
|
||||
out = Image.new("RGB", (W, H), BG)
|
||||
d = ImageDraw.Draw(out)
|
||||
titulo(d, "CÓMO VA LA RAMA ENCIMA DE OASIS",
|
||||
"Oasis lo cambia el dev, casi una versión al día. La rama solo cambia cómo se ve y se usa en el móvil.")
|
||||
capa = [
|
||||
((60, 210, 380, 400), "ASPECTO",
|
||||
["mobile_base.css: la maqueta", "mobile_ux.css: márgenes, letra,", "botones y piezas nuevas",
|
||||
"Acampada.css: el skin"], NEON),
|
||||
((400, 210, 720, 400), "PANTALLAS",
|
||||
["mobile_ui.js y las vistas", "mobile_*.js. En una vista", "del dev, como mucho una", "línea que las llama"], NEON),
|
||||
((740, 210, 1060, 400), "RUTAS",
|
||||
["fork_routes.js, delante", "del router del dev: lo", "que no atiende pasa al", "suyo"], NEON),
|
||||
((1080, 210, 1340, 400), "ANDROID",
|
||||
["la app del dev tal cual,", "con nuestro backend", "dentro y paquete", "propio"], NEON),
|
||||
]
|
||||
for box, t, l, r in capa:
|
||||
caja(d, box, t, l, r)
|
||||
d.text((60, 166), "LO DE LA RAMA", font=font(ANTON, 26), fill=FG)
|
||||
for x in (220, 560, 900, 1210):
|
||||
flecha(d, (x, 418), (x, 478))
|
||||
caja(d, (60, 500, 1340, 690), "OASIS DEL DEV: NO SE TOCA",
|
||||
["backend.js y su middleware · los modelos (lo que se publica y cómo se cuenta) · SSB y su arranque",
|
||||
"las traducciones · style.css y mobile.css · los temas, también OasisMobile.css",
|
||||
"Comparado con su APK oficial, la rama cambia 17 ficheros suyos y 561 líneas. Ver GUIA_DE_DESARROLLO.md"], BG)
|
||||
out.save(HERE / "esquema-rama.png", optimize=True)
|
||||
|
||||
|
||||
def esquema_llamadas():
|
||||
"""Las llamadas son las del dev; en Android pasan por un puente nativo de la app."""
|
||||
W, H = 1400, 700
|
||||
out = Image.new("RGB", (W, H), BG)
|
||||
d = ImageDraw.Draw(out)
|
||||
titulo(d, "LLAMADAS: LAS DEL DEV",
|
||||
"Teléfono y Salas son de Oasis. En Android el sonido y la cámara pasan por un puente nativo de la app.")
|
||||
cajas = [
|
||||
((50, 200, 360, 430), "TELÉFONO Y SALAS",
|
||||
["De Oasis, tal cual.", "Llamadas de uno a uno", "con buzón de voz; salas", "de hasta 50, con vídeo,", "grabación y emisión."], BG),
|
||||
((400, 200, 700, 430), "LOS ENCHUFES",
|
||||
["phone_*_android.js", "(del dev): se conectan", "a un puerto local que", "deja la app en", ".audio_bridge"], BG),
|
||||
((740, 200, 1040, 430), "EL PUENTE NATIVO",
|
||||
["Dentro de la app:", "R graba el micro", "P reproduce", "V saca la cámara", "S emite un fichero"], NEON),
|
||||
((1080, 200, 1350, 430), "EL MÓVIL",
|
||||
["Micrófono, cámara", "y altavoz, con los", "permisos que se piden", "la primera vez."], BG),
|
||||
]
|
||||
for box, t, l, r in cajas:
|
||||
caja(d, box, t, l, r)
|
||||
for a, b in (((362, 315), (398, 315)), ((702, 315), (738, 315)), ((1042, 315), (1078, 315))):
|
||||
flecha(d, a, b)
|
||||
caja(d, (50, 480, 1350, 630), "DÓNDE ESTÁ CADA COSA",
|
||||
["El puente está en la app Android del dev, que no tiene el código publicado. Por eso la APK de la",
|
||||
"rama se monta sobre la oficial: su app tal cual, nuestro backend dentro y paquete propio."], BG)
|
||||
out.save(HERE / "esquema-llamadas.png", optimize=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
portada()
|
||||
esquema()
|
||||
esquema_rama()
|
||||
esquema_llamadas()
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
# PDF de la guia para el dev
|
||||
|
||||
Fuente de Oasis_Mobile_Guia_de_desarrollo.pdf (5 paginas A4). Para sacarlo:
|
||||
|
||||
chromium --headless=new --no-pdf-header-footer --print-to-pdf=guia.pdf guia.html
|
||||
|
||||
Comprobar con `pdfinfo guia.pdf` que siguen siendo 5 paginas.
|
||||
|
|
@ -1,340 +0,0 @@
|
|||
<!doctype html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Oasis Mobile · Guía de desarrollo</title>
|
||||
<style>
|
||||
@font-face { font-family: "Anton"; src: url("file:///home/sito/COFRE/CODERS/OASIS_MOBILE/src/client/assets/fonts/Anton-Regular.ttf"); }
|
||||
@page { size: A4; margin: 0; }
|
||||
:root { --fg: #0b0b0b; --muted: #5a5a5a; --line: #0b0b0b; --cyan: #00d7f0; --soft: #f1f4f5; --red: #c4003b; --amber: #c98a00; --green: #0a8f3c; }
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; padding: 0; background: #fff; color: var(--fg); font-family: "Inter", "DejaVu Sans", sans-serif; font-size: 10.5pt; line-height: 1.45; }
|
||||
.page { width: 210mm; height: 297mm; padding: 16mm 17mm 14mm; position: relative; page-break-after: always; overflow: hidden; }
|
||||
.page:last-child { page-break-after: auto; }
|
||||
h1, h2, h3 { font-family: "Anton", Impact, sans-serif; font-weight: 400; text-transform: uppercase; margin: 0; letter-spacing: .01em; line-height: 1; }
|
||||
h1 { font-size: 54pt; }
|
||||
h2 { font-size: 30pt; margin-bottom: 4mm; }
|
||||
h2 + .rule { width: 34mm; height: 2.2mm; background: var(--fg); margin: 0 0 6mm; }
|
||||
h3 { font-size: 14pt; margin: 0 0 2.5mm; }
|
||||
p { margin: 0 0 3mm; }
|
||||
.lead { font-size: 12pt; color: var(--muted); margin-bottom: 6mm; }
|
||||
ul { margin: 0 0 3mm; padding-left: 5mm; }
|
||||
li { margin: 0 0 1.6mm; }
|
||||
li::marker { color: var(--fg); }
|
||||
code { font-family: "DejaVu Sans Mono", monospace; font-size: 8.8pt; background: var(--soft); padding: .2mm 1.2mm; border-radius: 1mm; }
|
||||
.foot { position: absolute; left: 17mm; right: 17mm; bottom: 8mm; display: flex; justify-content: space-between; font-size: 8pt; color: var(--muted); border-top: .4mm solid #ddd; padding-top: 2mm; }
|
||||
.cols { display: grid; grid-template-columns: 1fr 1fr; gap: 7mm; }
|
||||
.cols3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 5mm; }
|
||||
.card { border: .6mm solid var(--fg); border-radius: 3mm; padding: 4.5mm 5mm; }
|
||||
.card.cyan { background: #e2fbff; }
|
||||
.tag { display: inline-block; font-family: "Anton"; text-transform: uppercase; font-size: 11pt; padding: .6mm 3mm; border-radius: 1.5mm; color: #fff; margin-bottom: 3mm; }
|
||||
.tag.red { background: var(--red); } .tag.amber { background: var(--amber); } .tag.green { background: var(--green); }
|
||||
.num { font-family: "Anton"; font-size: 34pt; line-height: 1; }
|
||||
.kpi { display: grid; grid-template-columns: repeat(4, 1fr); gap: 4mm; margin: 6mm 0; }
|
||||
.kpi div { border-top: 1.2mm solid var(--fg); padding-top: 2mm; }
|
||||
.kpi span { display: block; font-size: 8.5pt; color: var(--muted); }
|
||||
figure { margin: 0 0 5mm; }
|
||||
figcaption { font-size: 8.5pt; color: var(--muted); margin-top: 1.5mm; }
|
||||
svg text { font-family: "Inter", sans-serif; }
|
||||
.cover { background: #fff; }
|
||||
.cover .band { position: absolute; left: 0; right: 0; top: 0; height: 9mm; background: var(--cyan); }
|
||||
.cover h1 { margin-top: 30mm; font-size: 64pt; }
|
||||
.cover .sub { font-family: "Anton"; text-transform: uppercase; font-size: 24pt; margin-top: 4mm; }
|
||||
.cover .meta { margin-top: 8mm; font-size: 11pt; color: var(--muted); }
|
||||
.ask { border-left: 2.2mm solid var(--cyan); background: var(--soft); padding: 4mm 5mm; border-radius: 0 3mm 3mm 0; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 9.5pt; }
|
||||
th { text-align: left; font-family: "Anton"; font-weight: 400; text-transform: uppercase; font-size: 10pt; border-bottom: .6mm solid var(--fg); padding: 1.5mm 2mm; }
|
||||
td { border-bottom: .3mm solid #ddd; padding: 1.6mm 2mm; vertical-align: top; }
|
||||
table.tight td { padding: 1.1mm 2mm; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- 1. PORTADA -->
|
||||
<section class="page cover">
|
||||
<div class="band"></div>
|
||||
<h1>Oasis Mobile</h1>
|
||||
<div class="sub">Guía de desarrollo de la rama del hacklab</div>
|
||||
<div class="meta">Propuesta para epsylon · base Oasis 1.3.3 · rama PRUEBAS · 19 de octubre de 2026</div>
|
||||
|
||||
<div class="kpi">
|
||||
<div><div class="num">39</div><span>versiones de Oasis entre la 1.0.1 y la 1.3.3</span></div>
|
||||
<div><div class="num">17</div><span>ficheros tuyos que la rama cambia, frente a tu APK oficial</span></div>
|
||||
<div><div class="num">2</div><span>líneas nuestras en <code>backend.js</code></span></div>
|
||||
<div><div class="num">0</div><span>cambios en modelos, middleware o traducciones</span></div>
|
||||
</div>
|
||||
|
||||
<h3>Qué es esta rama</h3>
|
||||
<ul>
|
||||
<li>Una forma de usar Oasis en el móvil pensada para una acampada: tribus como comisiones, votaciones de asamblea, salas de voz.</li>
|
||||
<li>No es otro Oasis. Es tu APK oficial con una capa encima: skins, unas pocas vistas, un router propio y textos.</li>
|
||||
<li>Se instala junto a la oficial con otro paquete (<code>net.laenre.oasis</code>) y habla con la misma red.</li>
|
||||
</ul>
|
||||
<h3>Qué te pedimos</h3>
|
||||
<ul>
|
||||
<li>Que revises si la frontera que proponemos (página 3) te parece bien: qué es tuyo y no tocamos, y qué es nuestro.</li>
|
||||
<li>Que nos digas si alguno de los puntos de la página 5 te interesa para Oasis, para dejar de llevarlo nosotros.</li>
|
||||
</ul>
|
||||
|
||||
<figure style="margin-top:6mm">
|
||||
<svg viewBox="0 0 700 150" width="100%">
|
||||
<defs><marker id="ar0" markerWidth="10" markerHeight="10" refX="9" refY="5" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#0b0b0b"/></marker></defs>
|
||||
<rect x="2" y="20" width="200" height="110" rx="10" fill="#fff" stroke="#0b0b0b" stroke-width="2.5"/>
|
||||
<text x="102" y="58" text-anchor="middle" font-size="17" font-weight="700">Tu APK oficial</text>
|
||||
<text x="102" y="82" text-anchor="middle" font-size="12" fill="#5a5a5a">app Android, puente de</text>
|
||||
<text x="102" y="98" text-anchor="middle" font-size="12" fill="#5a5a5a">audio, node_modules</text>
|
||||
<rect x="250" y="20" width="200" height="110" rx="10" fill="#e2fbff" stroke="#0b0b0b" stroke-width="2.5"/>
|
||||
<text x="350" y="58" text-anchor="middle" font-size="17" font-weight="700">+ capa de la rama</text>
|
||||
<text x="350" y="82" text-anchor="middle" font-size="12" fill="#5a5a5a">skins, vistas mobile_*,</text>
|
||||
<text x="350" y="98" text-anchor="middle" font-size="12" fill="#5a5a5a">router propio, textos</text>
|
||||
<rect x="498" y="20" width="200" height="110" rx="10" fill="#0b0b0b" stroke="#0b0b0b" stroke-width="2.5"/>
|
||||
<text x="598" y="58" text-anchor="middle" font-size="17" font-weight="700" fill="#fff">Oasis hacklab</text>
|
||||
<text x="598" y="82" text-anchor="middle" font-size="12" fill="#ddd">otro paquete, misma red,</text>
|
||||
<text x="598" y="98" text-anchor="middle" font-size="12" fill="#ddd">convive con la oficial</text>
|
||||
<line x1="204" y1="75" x2="244" y2="75" stroke="#0b0b0b" stroke-width="2.5" marker-end="url(#ar0)"/>
|
||||
<line x1="452" y1="75" x2="492" y2="75" stroke="#0b0b0b" stroke-width="2.5" marker-end="url(#ar0)"/>
|
||||
</svg>
|
||||
</figure>
|
||||
<div class="foot"><span>Oasis Mobile · Guía de desarrollo</span><span>1 / 5</span></div>
|
||||
</section>
|
||||
|
||||
<!-- 2. LA REGLA -->
|
||||
<section class="page">
|
||||
<h2>La regla</h2><div class="rule"></div>
|
||||
<p class="lead">Oasis lo cambias tú. Nosotros cambiamos cómo se ve y se usa en el móvil, y lo hacemos en ficheros nuestros.</p>
|
||||
|
||||
<figure>
|
||||
<svg viewBox="0 0 700 330" width="100%">
|
||||
<defs><marker id="ar1" markerWidth="10" markerHeight="10" refX="9" refY="5" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#0b0b0b"/></marker></defs>
|
||||
<text x="0" y="16" font-size="13" font-weight="700" letter-spacing="1">LA CAPA DE LA RAMA</text>
|
||||
<g font-size="12">
|
||||
<rect x="0" y="28" width="166" height="96" rx="9" fill="#e2fbff" stroke="#0b0b0b" stroke-width="2"/>
|
||||
<text x="83" y="54" text-anchor="middle" font-size="15" font-weight="700">Aspecto</text>
|
||||
<text x="83" y="76" text-anchor="middle">mobile_base.css</text><text x="83" y="92" text-anchor="middle">mobile_ux.css</text><text x="83" y="108" text-anchor="middle">Acampada.css</text>
|
||||
<rect x="178" y="28" width="166" height="96" rx="9" fill="#e2fbff" stroke="#0b0b0b" stroke-width="2"/>
|
||||
<text x="261" y="54" text-anchor="middle" font-size="15" font-weight="700">Pantallas</text>
|
||||
<text x="261" y="76" text-anchor="middle">mobile_ui.js</text><text x="261" y="92" text-anchor="middle">mobile_*_view.js</text><text x="261" y="108" text-anchor="middle">hive_views.js</text>
|
||||
<rect x="356" y="28" width="166" height="96" rx="9" fill="#e2fbff" stroke="#0b0b0b" stroke-width="2"/>
|
||||
<text x="439" y="54" text-anchor="middle" font-size="15" font-weight="700">Rutas</text>
|
||||
<text x="439" y="76" text-anchor="middle">fork_routes.js</text><text x="439" y="92" text-anchor="middle">mobile_layer.js</text><text x="439" y="108" text-anchor="middle">fork_workflows.js</text>
|
||||
<rect x="534" y="28" width="166" height="96" rx="9" fill="#e2fbff" stroke="#0b0b0b" stroke-width="2"/>
|
||||
<text x="617" y="54" text-anchor="middle" font-size="15" font-weight="700">Textos y APK</text>
|
||||
<text x="617" y="76" text-anchor="middle">i18n_fork.js</text><text x="617" y="92" text-anchor="middle">(11 idiomas)</text><text x="617" y="108" text-anchor="middle">sobre_oficial.sh</text>
|
||||
</g>
|
||||
<g stroke="#0b0b0b" stroke-width="2" marker-end="url(#ar1)">
|
||||
<line x1="83" y1="130" x2="83" y2="176"/><line x1="261" y1="130" x2="261" y2="176"/><line x1="439" y1="130" x2="439" y2="176"/><line x1="617" y1="130" x2="617" y2="176"/>
|
||||
</g>
|
||||
<text x="270" y="158" font-size="11" fill="#5a5a5a">una línea de enganche, solo con OASIS_MOBILE=1</text>
|
||||
<rect x="0" y="184" width="700" height="140" rx="10" fill="#fff" stroke="#0b0b0b" stroke-width="3"/>
|
||||
<text x="350" y="216" text-anchor="middle" font-size="18" font-weight="700">OASIS: TU CÓDIGO, TAL CUAL</text>
|
||||
<g font-size="12" text-anchor="middle">
|
||||
<rect x="18" y="234" width="152" height="70" rx="7" fill="#f1f4f5"/><text x="94" y="262" font-weight="700">backend.js</text><text x="94" y="280" fill="#5a5a5a">y su middleware</text>
|
||||
<rect x="188" y="234" width="152" height="70" rx="7" fill="#f1f4f5"/><text x="264" y="262" font-weight="700">modelos</text><text x="264" y="280" fill="#5a5a5a">qué se publica y se cuenta</text>
|
||||
<rect x="358" y="234" width="152" height="70" rx="7" fill="#f1f4f5"/><text x="434" y="262" font-weight="700">SSB y servidor</text><text x="434" y="280" fill="#5a5a5a">phone_module, puente</text>
|
||||
<rect x="528" y="234" width="154" height="70" rx="7" fill="#f1f4f5"/><text x="605" y="262" font-weight="700">traducciones y temas</text><text x="605" y="280" fill="#5a5a5a">oasis_*.js, style.css…</text>
|
||||
</g>
|
||||
</svg>
|
||||
<figcaption>La rama son cuatro piezas propias encima de tu código. Lo único que entra en tus ficheros son líneas de enganche.</figcaption>
|
||||
</figure>
|
||||
|
||||
<div class="cols">
|
||||
<div>
|
||||
<h3>Por qué así</h3>
|
||||
<ul>
|
||||
<li>Sacas versión casi cada día y cada una llega en un solo commit: no se pueden coger cambios sueltos.</li>
|
||||
<li><code>backend.js</code> cambió en 34 de 39 versiones, <code>main_views.js</code> en 31, las traducciones en 31.</li>
|
||||
<li>Cada línea nuestra en esos ficheros sería un conflicto en la siguiente integración.</li>
|
||||
<li>Si tocáramos un modelo, nuestro móvil contaría distinto que tu escritorio (votos, tribus, gobierno).</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h3>Cómo se mide</h3>
|
||||
<ul>
|
||||
<li>La referencia para el móvil es el <code>nodejs-project</code> de tu APK oficial, no tu git: ahí van tus cambios de móvil.</li>
|
||||
<li>Hoy la rama cambia 17 ficheros tuyos y 561 líneas; en <code>backend.js</code>, 2.</li>
|
||||
<li>En cada versión tuya se repite la comparación y se revisa si ha desaparecido alguna clase CSS de la que tiramos.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="foot"><span>Oasis Mobile · Guía de desarrollo</span><span>2 / 5</span></div>
|
||||
</section>
|
||||
|
||||
<!-- 3. LA FRONTERA -->
|
||||
<section class="page">
|
||||
<h2>Qué se toca y qué no</h2><div class="rule"></div>
|
||||
<p class="lead">Tres zonas. La de en medio es la única que entra en tus ficheros, y solo con una línea.</p>
|
||||
<div class="cols3">
|
||||
<div class="card">
|
||||
<span class="tag red">No se toca</span>
|
||||
<ul>
|
||||
<li><code>backend.js</code>, rutas y cadena de middleware</li>
|
||||
<li>Modelos de <code>src/models</code>: lo que se publica en el log y cómo se cuenta</li>
|
||||
<li><code>src/server</code>: arranque de SSB, dependencias, <code>phone_module</code></li>
|
||||
<li><code>request_guards.js</code> y la CSP: nada en línea</li>
|
||||
<li>Traducciones <code>oasis_*.js</code></li>
|
||||
<li><code>style.css</code>, <code>mobile.css</code> y los temas, también <code>OasisMobile.css</code></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="card">
|
||||
<span class="tag amber">Una línea</span>
|
||||
<ul>
|
||||
<li>Tus vistas: <code>main_views.js</code>, <code>tribes_view.js</code>, <code>vote_view.js</code>, <code>settings_view.js</code>, <code>welcome_view.js</code></li>
|
||||
<li>La lógica va en <code>mobile_ui.js</code>; en tu vista, una llamada</li>
|
||||
<li>Siempre detrás de <code>OASIS_MOBILE === '1'</code>: en escritorio todo queda como lo dejas</li>
|
||||
<li>Si reescribes la vista, se vuelve a poner la línea</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="card cyan">
|
||||
<span class="tag green">Nuestro</span>
|
||||
<ul>
|
||||
<li>Hojas: <code>mobile_base.css</code>, <code>mobile_ux.css</code>, <code>Acampada.css</code></li>
|
||||
<li>Vistas: <code>mobile_ui.js</code>, <code>mobile_modules_view.js</code>, <code>hive_views.js</code>, <code>mobile_icons.js</code></li>
|
||||
<li>Router: <code>fork_routes.js</code> y <code>mobile_layer.js</code></li>
|
||||
<li>Workflows: <code>fork_workflows.js</code></li>
|
||||
<li>Textos: <code>i18n_fork.js</code></li>
|
||||
<li>APK: <code>sobre_oficial.sh</code></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 style="margin-top:4mm">Ejemplos concretos</h3>
|
||||
<table class="tight">
|
||||
<tr><th>Lo que hacía falta</th><th>Cómo se ha hecho</th><th>Zona</th></tr>
|
||||
<tr><td>Votar en semáforo o termómetro, como en asamblea</td><td>Tu servidor ya acepta <code>options</code>; solo se añade el selector en el formulario</td><td>Una línea</td></tr>
|
||||
<tr><td>Quien crea una votación no puede votar</td><td>Es una regla de tu modelo: no se cambia, se explica en pantalla</td><td>Nuestro</td></tr>
|
||||
<tr><td>Avisar de que una sección está apagada y encenderla</td><td>Nuestro router mira tu redirección a <code>/modules</code>, sin tocar <code>checkMod</code></td><td>Nuestro</td></tr>
|
||||
<tr><td>Workflow para acampadas</td><td>Se registra <code>acampada</code> en tus arrays; <code>activists</code> queda como el tuyo</td><td>Nuestro</td></tr>
|
||||
<tr><td>Llamadas y salas</td><td>Las tuyas, con tu puente de audio: la APK se monta sobre la oficial</td><td>No se toca</td></tr>
|
||||
<tr><td>Borrar con confirmación</td><td>Función en <code>mobile_ui.js</code>, enganchada en <code>renderContentActions</code></td><td>Una línea</td></tr>
|
||||
</table>
|
||||
<div class="foot"><span>Oasis Mobile · Guía de desarrollo</span><span>3 / 5</span></div>
|
||||
</section>
|
||||
|
||||
<!-- 4. CÓMO FUNCIONA -->
|
||||
<section class="page">
|
||||
<h2>Cómo funciona por dentro</h2><div class="rule"></div>
|
||||
|
||||
<h3>Una petición en el móvil</h3>
|
||||
<figure>
|
||||
<svg viewBox="0 0 700 175" width="100%">
|
||||
<defs><marker id="ar2" markerWidth="10" markerHeight="10" refX="9" refY="5" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#0b0b0b"/></marker></defs>
|
||||
<g font-size="12" text-anchor="middle">
|
||||
<rect x="0" y="40" width="110" height="80" rx="9" fill="#fff" stroke="#0b0b0b" stroke-width="2"/><text x="55" y="76" font-weight="700">WebView</text><text x="55" y="94" fill="#5a5a5a">de tu app</text>
|
||||
<rect x="140" y="40" width="130" height="80" rx="9" fill="#f1f4f5" stroke="#0b0b0b" stroke-width="2"/><text x="205" y="70" font-weight="700">Tu middleware</text><text x="205" y="88" fill="#5a5a5a">contexto, idioma,</text><text x="205" y="102" fill="#5a5a5a">CSP</text>
|
||||
<rect x="300" y="40" width="130" height="80" rx="9" fill="#e2fbff" stroke="#0b0b0b" stroke-width="2"/><text x="365" y="70" font-weight="700">fork_routes</text><text x="365" y="88" fill="#5a5a5a">rutas propias o</text><text x="365" y="102" fill="#5a5a5a">next() al tuyo</text>
|
||||
<rect x="460" y="40" width="110" height="80" rx="9" fill="#f1f4f5" stroke="#0b0b0b" stroke-width="2"/><text x="515" y="76" font-weight="700">Tus rutas</text><text x="515" y="94" fill="#5a5a5a">y vistas</text>
|
||||
<rect x="600" y="40" width="100" height="80" rx="9" fill="#e2fbff" stroke="#0b0b0b" stroke-width="2"/><text x="650" y="70" font-weight="700">mobile_ui</text><text x="650" y="88" fill="#5a5a5a">si la vista</text><text x="650" y="102" fill="#5a5a5a">lo llama</text>
|
||||
</g>
|
||||
<g stroke="#0b0b0b" stroke-width="2" marker-end="url(#ar2)">
|
||||
<line x1="112" y1="80" x2="134" y2="80"/><line x1="272" y1="80" x2="294" y2="80"/><line x1="432" y1="80" x2="454" y2="80"/><line x1="572" y1="80" x2="594" y2="80"/>
|
||||
</g>
|
||||
<path d="M365 124 C365 160, 515 160, 515 124" fill="none" stroke="#0b0b0b" stroke-width="1.6" stroke-dasharray="5 4"/>
|
||||
<text x="440" y="168" text-anchor="middle" font-size="11" fill="#5a5a5a">wrapUpstream mira tu respuesta (redirecciones) sin cambiarla</text>
|
||||
<text x="0" y="22" font-size="11" fill="#5a5a5a">Nuestro router va justo delante del tuyo: una sola línea en backend.js lo monta.</text>
|
||||
</svg>
|
||||
</figure>
|
||||
|
||||
<div class="cols">
|
||||
<div>
|
||||
<h3>Hojas de estilo, en este orden</h3>
|
||||
<figure>
|
||||
<svg viewBox="0 0 330 200" width="100%">
|
||||
<g font-size="12">
|
||||
<rect x="0" y="0" width="330" height="34" rx="6" fill="#f1f4f5" stroke="#0b0b0b"/><text x="12" y="22">1 · style.css y mobile.css (tuyas)</text>
|
||||
<rect x="0" y="40" width="330" height="34" rx="6" fill="#e2fbff" stroke="#0b0b0b"/><text x="12" y="62">2 · mobile_base.css: maqueta de la rama</text>
|
||||
<rect x="0" y="80" width="330" height="34" rx="6" fill="#fff" stroke="#0b0b0b"/><text x="12" y="102">3 · skin: Acampada.css o tu OasisMobile.css</text>
|
||||
<rect x="0" y="120" width="330" height="34" rx="6" fill="#e2fbff" stroke="#0b0b0b"/><text x="12" y="142">4 · hive_acampada.css (solo con Acampada)</text>
|
||||
<rect x="0" y="160" width="330" height="34" rx="6" fill="#0b0b0b"/><text x="12" y="182" fill="#fff">5 · mobile_ux.css: piezas comunes, la última</text>
|
||||
</g>
|
||||
</svg>
|
||||
</figure>
|
||||
<ul>
|
||||
<li>Con tu skin OasisMobile se ve tu tema tal cual, encima de nuestra maqueta.</li>
|
||||
<li>Colores por variables <code>--ux-*</code>: valen para los dos skins.</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h3>La APK, montada sobre la tuya</h3>
|
||||
<figure>
|
||||
<svg viewBox="0 0 330 200" width="100%">
|
||||
<defs><marker id="ar3" markerWidth="10" markerHeight="10" refX="9" refY="5" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#0b0b0b"/></marker></defs>
|
||||
<g font-size="12">
|
||||
<rect x="0" y="0" width="330" height="30" rx="6" fill="#fff" stroke="#0b0b0b"/><text x="12" y="20">tu APK oficial de la misma versión</text>
|
||||
<rect x="0" y="42" width="330" height="30" rx="6" fill="#f1f4f5" stroke="#0b0b0b"/><text x="12" y="62">se desmonta (apktool, versión fija)</text>
|
||||
<rect x="0" y="84" width="330" height="30" rx="6" fill="#e2fbff" stroke="#0b0b0b"/><text x="12" y="104">backend de la rama + tu node_modules</text>
|
||||
<rect x="0" y="126" width="330" height="30" rx="6" fill="#e2fbff" stroke="#0b0b0b"/><text x="12" y="146">paquete net.laenre.oasis</text>
|
||||
<rect x="0" y="168" width="330" height="30" rx="6" fill="#0b0b0b"/><text x="12" y="188" fill="#fff">se monta, se alinea y se firma</text>
|
||||
</g>
|
||||
<g stroke="#0b0b0b" stroke-width="1.6" marker-end="url(#ar3)"><line x1="300" y1="31" x2="300" y2="40"/><line x1="300" y1="73" x2="300" y2="82"/><line x1="300" y1="115" x2="300" y2="124"/><line x1="300" y1="157" x2="300" y2="166"/></g>
|
||||
</svg>
|
||||
</figure>
|
||||
<ul>
|
||||
<li>Tu app tal cual: puente de audio, notificaciones, permisos.</li>
|
||||
<li>Se cambian también los authorities, el permiso propio y las acciones de llamada, para no cruzarse con la tuya.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="foot"><span>Oasis Mobile · Guía de desarrollo</span><span>4 / 5</span></div>
|
||||
</section>
|
||||
|
||||
<!-- 5. TRABAJAR JUNTOS -->
|
||||
<section class="page">
|
||||
<h2>Cómo seguimos</h2><div class="rule"></div>
|
||||
|
||||
<div class="cols">
|
||||
<div>
|
||||
<h3>Antes de cada commit</h3>
|
||||
<ul>
|
||||
<li>Nada de la zona roja en el diff; en tus vistas, solo líneas de enganche.</li>
|
||||
<li><code>node --check</code> y el servidor local responde en las pantallas principales.</li>
|
||||
<li>Visto a 360 y 412 px, en tableta, con la letra al 130 % y en un WebView viejo.</li>
|
||||
<li>Textos nuevos en los 11 idiomas, en <code>i18n_fork.js</code>.</li>
|
||||
<li>Nada en línea: la CSP lo bloquea.</li>
|
||||
</ul>
|
||||
<h3>Cuando sacas versión</h3>
|
||||
<ul>
|
||||
<li>Se coge la tuya tal cual; para el móvil, la de tu APK.</li>
|
||||
<li>Se vuelven a poner los enganches en tus vistas.</li>
|
||||
<li>Se repite la medición y se revisan las clases CSS.</li>
|
||||
<li>Se monta la APK sobre tu oficial de esa versión.</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h3>Navegación en el móvil</h3>
|
||||
<ul>
|
||||
<li>Al abrir se ve la actividad, no un menú.</li>
|
||||
<li>Abajo, cuatro atajos que elige cada cual y una pestaña Menú que abre todos los módulos desde abajo.</li>
|
||||
<li>Probado en el emulador: dos toques a cualquier módulo desde cualquier pantalla.</li>
|
||||
</ul>
|
||||
<h3>Probado</h3>
|
||||
<ul>
|
||||
<li>Android 16 (APK real): 412 y 360 dp, tableta de 800 dp, letra al 130 %, gestos y tres botones.</li>
|
||||
<li>Motores viejos: WebView 69 (Android 9) y 83 (Android 11), con alternativas donde falta <code>:has</code>, <code>gap</code> o <code>inset</code>.</li>
|
||||
<li>Llamadas: en una sala el micrófono graba y se corta al salir.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 style="margin-top:2mm">Lo que podría pasar a Oasis</h3>
|
||||
<p>Cosas que hoy llevamos en tus ficheros y que nos gustaría que fueran tuyas, para dejar de mantenerlas aparte:</p>
|
||||
<table>
|
||||
<tr><th>Qué</th><th>Dónde está ahora</th><th>Por qué te puede interesar</th></tr>
|
||||
<tr><td>Limpiar metadatos de las fotos también en Android</td><td><code>blobHandler.js</code> (190 líneas)</td><td>Sin <code>sharp</code> en el móvil, hoy las fotos suben con el GPS dentro</td></tr>
|
||||
<tr><td>Varias identidades en el mismo móvil</td><td><code>ssb_config.js</code> (30 líneas)</td><td>Una persona, varias cuentas, con aviso si dos comparten clave</td></tr>
|
||||
<tr><td>DNS del sistema primero</td><td><code>main.js</code> (45 líneas)</td><td>Respeta la VPN y el DNS privado de Android</td></tr>
|
||||
<tr><td>Selector de idioma con el idioma actual</td><td>nuestro, en <code>mobile_ui.js</code></td><td><code>settings_view</code> lee <code>selectedLanguage</code> al cargar y siempre marca English</td></tr>
|
||||
</table>
|
||||
|
||||
<div class="ask" style="margin-top:3mm">
|
||||
<h3>Para aprobar</h3>
|
||||
<ul style="margin:0">
|
||||
<li>¿Te parece bien la frontera de la página 3?</li>
|
||||
<li>¿Te parece bien que la APK de la rama se monte sobre tu oficial, con otro paquete?</li>
|
||||
<li>¿Quieres alguno de los cuatro puntos de arriba en Oasis?</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="foot"><span>Oasis Mobile · Guía de desarrollo · hacklab · gitea.laenre.net/hacklab/OASIS_MOBILE</span><span>5 / 5</span></div>
|
||||
</section>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
199
main.js
|
|
@ -8,13 +8,6 @@ process.env.CHLORIDE_JS = '1';
|
|||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
try {
|
||||
const androidInfo = JSON.parse(fs.readFileSync(path.join(__dirname, '.android.json'), 'utf8'));
|
||||
if (androidInfo.versionName) process.env.OASIS_ANDROID_VERSION = String(androidInfo.versionName);
|
||||
if (androidInfo.versionCode) process.env.OASIS_ANDROID_VERSION_CODE = String(androidInfo.versionCode);
|
||||
process.env.OASIS_ANDROID_INSTALLER = String(androidInfo.installer || '');
|
||||
} catch (e) {}
|
||||
|
||||
const tmpDir = path.join(process.env.HOME, 'tmp');
|
||||
try { fs.mkdirSync(tmpDir, { recursive: true }); } catch (e) {}
|
||||
process.env.TMPDIR = tmpDir;
|
||||
|
|
@ -26,8 +19,6 @@ try {
|
|||
const dns = require('dns');
|
||||
const net = require('net');
|
||||
const servers = ['8.8.8.8', '1.1.1.1', '8.8.4.4'];
|
||||
const systemLookup = dns.lookup;
|
||||
const SYSTEM_LOOKUP_MS = 5000;
|
||||
try { dns.setServers(servers); } catch (e) {}
|
||||
try { if (dns.promises && dns.promises.setServers) dns.promises.setServers(servers); } catch (e) {}
|
||||
|
||||
|
|
@ -50,43 +41,11 @@ try {
|
|||
? callback(null, [direct])
|
||||
: callback(null, direct.address, direct.family));
|
||||
}
|
||||
// rama movil: primero el resolutor del sistema (getaddrinfo), que es el que
|
||||
// respeta la VPN y el DNS privado de Android. Upstream iba siempre a
|
||||
// 8.8.8.8/1.1.1.1, y las VPN que evitan fugas de DNS (Mullvad, por ejemplo)
|
||||
// o las redes que bloquean DNS externos los rechazan: no se encontraba
|
||||
// ningun pub por nombre y unirse a uno "no hacia nada". Los publicos quedan
|
||||
// de respaldo, para los moviles donde getaddrinfo no contesta.
|
||||
// Los dos caminos compiten: el del sistema sale ya y, si en SYSTEM_LOOKUP_MS
|
||||
// no ha contestado o falla, se pregunta tambien a los publicos. Gana la primera
|
||||
// respuesta buena; si fallan los dos, se devuelve el error del sistema, que
|
||||
// dice mas ("no existe") que el de unos servidores quiza bloqueados.
|
||||
let done = false, sysErr = null, pubErr = null, sysDone = false, pubDone = false, pubStarted = false;
|
||||
const finish = (err, address, fam) => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
clearTimeout(timer);
|
||||
if (err) return callback(err);
|
||||
if (options.all) return callback(null, Array.isArray(address) ? address : [{ address, family: fam }]);
|
||||
if (Array.isArray(address)) return callback(null, address[0].address, address[0].family);
|
||||
return callback(null, address, fam);
|
||||
};
|
||||
const bothFailed = () => { if (sysDone && pubDone) finish(sysErr || pubErr || new Error('ENOTFOUND ' + hostname)); };
|
||||
const viaPublic = () => {
|
||||
if (pubStarted) return;
|
||||
pubStarted = true;
|
||||
const resolver = family === 6 ? dns.resolve6 : dns.resolve4;
|
||||
resolver.call(dns, hostname, (err, addrs) => {
|
||||
pubDone = true;
|
||||
if (err || !addrs || !addrs.length) { pubErr = err || new Error('ENOTFOUND ' + hostname); return bothFailed(); }
|
||||
finish(null, options.all ? addrs.map((x) => ({ address: x, family })) : addrs[0], family);
|
||||
});
|
||||
};
|
||||
const timer = setTimeout(viaPublic, SYSTEM_LOOKUP_MS);
|
||||
systemLookup.call(dns, hostname, { family, all: !!options.all }, (err, address, fam) => {
|
||||
sysDone = true;
|
||||
if (!err) return finish(null, address, fam);
|
||||
sysErr = err;
|
||||
if (pubStarted) bothFailed(); else viaPublic();
|
||||
const resolver = family === 6 ? dns.resolve6 : dns.resolve4;
|
||||
resolver.call(dns, hostname, (err, addrs) => {
|
||||
if (err || !addrs || !addrs.length) return callback(err || new Error('ENOTFOUND ' + hostname));
|
||||
if (options.all) return callback(null, addrs.map((a) => ({ address: a, family })));
|
||||
return callback(null, addrs[0], family);
|
||||
});
|
||||
};
|
||||
|
||||
|
|
@ -108,9 +67,153 @@ console.log('[Oasis] Launching Oasis Social Network Utopia...');
|
|||
console.log('[Oasis] Backend path:', backendPath);
|
||||
console.log('[Oasis] HOME:', process.env.HOME);
|
||||
|
||||
try {
|
||||
const http = require('http');
|
||||
const keylock = require('./src/models/keylock');
|
||||
const configPath = path.join(process.env.HOME, '.ssb');
|
||||
|
||||
let backendStarted = false;
|
||||
function startBackend() {
|
||||
if (backendStarted) return;
|
||||
backendStarted = true;
|
||||
try {
|
||||
require(backendPath);
|
||||
console.log('[Oasis] Backend started on port 3000');
|
||||
} catch (error) {
|
||||
} catch (error) {
|
||||
console.error('[Oasis] Error starting backend:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function unlockAndStart(keys) {
|
||||
global.__OASIS_KEYS__ = keys;
|
||||
for (const s of sockets) { try { s.destroy(); } catch (_) {} }
|
||||
try { lockServer.close(() => startBackend()); } catch (_) { startBackend(); }
|
||||
}
|
||||
|
||||
const MODE = keylock.isConfigured(configPath) ? 'unlock' : 'setup';
|
||||
|
||||
const LOCK_LANGS = ['en','es','fr','eu','de','it','pt','zh','ar','hi','ru'];
|
||||
const LOCK_I18N = {
|
||||
en: { name:"English", t_setup:"Create your password", t_unlock:"Unlock Oasis", s_setup:"It encrypts your identity and keys on this device. <b>If you forget it, you lose the account</b> (no recovery).", s_unlock:"Enter your password to decrypt your keys and start Oasis.", ph:"Password", ph2:"Repeat password", b_setup:"Create and enter", b_unlock:"Enter", e_min:"Minimum 6 characters", e_mism:"Passwords do not match", e_wrong:"Incorrect password", e_pre:"Error: ", m_unlock:"Unlocking…" },
|
||||
es: { name:"Español", t_setup:"Crea tu contraseña", t_unlock:"Desbloquea Oasis", s_setup:"Cifrará tu identidad y tus claves en este dispositivo. <b>Si la olvidas, pierdes la cuenta</b> (no hay recuperación).", s_unlock:"Introduce tu contraseña para descifrar tus claves y arrancar Oasis.", ph:"Contraseña", ph2:"Repite la contraseña", b_setup:"Crear y entrar", b_unlock:"Entrar", e_min:"Mínimo 6 caracteres", e_mism:"Las contraseñas no coinciden", e_wrong:"Contraseña incorrecta", e_pre:"Error: ", m_unlock:"Desbloqueando…" },
|
||||
fr: { name:"Français", t_setup:"Créez votre mot de passe", t_unlock:"Déverrouiller Oasis", s_setup:"Il chiffre votre identité et vos clés sur cet appareil. <b>Si vous l'oubliez, vous perdez le compte</b> (aucune récupération).", s_unlock:"Saisissez votre mot de passe pour déchiffrer vos clés et démarrer Oasis.", ph:"Mot de passe", ph2:"Répétez le mot de passe", b_setup:"Créer et entrer", b_unlock:"Entrer", e_min:"6 caractères minimum", e_mism:"Les mots de passe ne correspondent pas", e_wrong:"Mot de passe incorrect", e_pre:"Erreur : ", m_unlock:"Déverrouillage…" },
|
||||
eu: { name:"Euskara", t_setup:"Sortu zure pasahitza", t_unlock:"Desblokeatu Oasis", s_setup:"Zure identitatea eta gakoak gailu honetan zifratzen ditu. <b>Ahazten baduzu, kontua galtzen duzu</b> (ez dago berreskuratzerik).", s_unlock:"Sartu zure pasahitza gakoak deszifratu eta Oasis abiarazteko.", ph:"Pasahitza", ph2:"Errepikatu pasahitza", b_setup:"Sortu eta sartu", b_unlock:"Sartu", e_min:"Gutxienez 6 karaktere", e_mism:"Pasahitzak ez datoz bat", e_wrong:"Pasahitz okerra", e_pre:"Errorea: ", m_unlock:"Desblokeatzen…" },
|
||||
de: { name:"Deutsch", t_setup:"Erstelle dein Passwort", t_unlock:"Oasis entsperren", s_setup:"Es verschlüsselt deine Identität und Schlüssel auf diesem Gerät. <b>Wenn du es vergisst, verlierst du das Konto</b> (keine Wiederherstellung).", s_unlock:"Gib dein Passwort ein, um deine Schlüssel zu entschlüsseln und Oasis zu starten.", ph:"Passwort", ph2:"Passwort wiederholen", b_setup:"Erstellen und eintreten", b_unlock:"Eintreten", e_min:"Mindestens 6 Zeichen", e_mism:"Passwörter stimmen nicht überein", e_wrong:"Falsches Passwort", e_pre:"Fehler: ", m_unlock:"Entsperren…" },
|
||||
it: { name:"Italiano", t_setup:"Crea la tua password", t_unlock:"Sblocca Oasis", s_setup:"Cifra la tua identità e le tue chiavi su questo dispositivo. <b>Se la dimentichi, perdi l'account</b> (nessun recupero).", s_unlock:"Inserisci la password per decifrare le chiavi e avviare Oasis.", ph:"Password", ph2:"Ripeti la password", b_setup:"Crea ed entra", b_unlock:"Entra", e_min:"Minimo 6 caratteri", e_mism:"Le password non coincidono", e_wrong:"Password errata", e_pre:"Errore: ", m_unlock:"Sblocco…" },
|
||||
pt: { name:"Português", t_setup:"Crie a sua palavra-passe", t_unlock:"Desbloquear o Oasis", s_setup:"Cifra a sua identidade e chaves neste dispositivo. <b>Se a esquecer, perde a conta</b> (sem recuperação).", s_unlock:"Introduza a sua palavra-passe para decifrar as chaves e iniciar o Oasis.", ph:"Palavra-passe", ph2:"Repita a palavra-passe", b_setup:"Criar e entrar", b_unlock:"Entrar", e_min:"Mínimo 6 caracteres", e_mism:"As palavras-passe não coincidem", e_wrong:"Palavra-passe incorreta", e_pre:"Erro: ", m_unlock:"A desbloquear…" },
|
||||
zh: { name:"中文", t_setup:"创建你的密码", t_unlock:"解锁 Oasis", s_setup:"它会在此设备上加密你的身份和密钥。<b>如果忘记,你将失去账户</b>(无法恢复)。", s_unlock:"输入密码以解密你的密钥并启动 Oasis。", ph:"密码", ph2:"重复密码", b_setup:"创建并进入", b_unlock:"进入", e_min:"至少 6 个字符", e_mism:"密码不一致", e_wrong:"密码错误", e_pre:"错误:", m_unlock:"正在解锁…" },
|
||||
ar: { name:"العربية", t_setup:"أنشئ كلمة المرور", t_unlock:"فتح Oasis", s_setup:"يشفّر هويتك ومفاتيحك على هذا الجهاز. <b>إذا نسيتها، تفقد الحساب</b> (لا استعادة).", s_unlock:"أدخل كلمة المرور لفك تشفير مفاتيحك وتشغيل Oasis.", ph:"كلمة المرور", ph2:"أعد كلمة المرور", b_setup:"إنشاء ودخول", b_unlock:"دخول", e_min:"6 أحرف على الأقل", e_mism:"كلمتا المرور غير متطابقتين", e_wrong:"كلمة مرور غير صحيحة", e_pre:"خطأ: ", m_unlock:"جارٍ الفتح…" },
|
||||
hi: { name:"हिन्दी", t_setup:"अपना पासवर्ड बनाएं", t_unlock:"Oasis अनलॉक करें", s_setup:"यह इस डिवाइस पर आपकी पहचान और कुंजियाँ एन्क्रिप्ट करता है। <b>भूल गए तो खाता खो देंगे</b> (कोई रिकवरी नहीं)।", s_unlock:"अपनी कुंजियाँ डिक्रिप्ट करने और Oasis शुरू करने के लिए पासवर्ड दर्ज करें।", ph:"पासवर्ड", ph2:"पासवर्ड दोहराएँ", b_setup:"बनाएँ और प्रवेश करें", b_unlock:"प्रवेश करें", e_min:"कम से कम 6 अक्षर", e_mism:"पासवर्ड मेल नहीं खाते", e_wrong:"गलत पासवर्ड", e_pre:"त्रुटि: ", m_unlock:"अनलॉक हो रहा है…" },
|
||||
ru: { name:"Русский", t_setup:"Создайте пароль", t_unlock:"Разблокировать Oasis", s_setup:"Он шифрует вашу личность и ключи на этом устройстве. <b>Если забудете, потеряете аккаунт</b> (без восстановления).", s_unlock:"Введите пароль, чтобы расшифровать ключи и запустить Oasis.", ph:"Пароль", ph2:"Повторите пароль", b_setup:"Создать и войти", b_unlock:"Войти", e_min:"Минимум 6 символов", e_mism:"Пароли не совпадают", e_wrong:"Неверный пароль", e_pre:"Ошибка: ", m_unlock:"Разблокировка…" },
|
||||
};
|
||||
|
||||
function lockPage(mode) {
|
||||
const confirmField = mode === 'setup'
|
||||
? '<input id="c" type="password" inputmode="text" autocomplete="new-password" />'
|
||||
: '';
|
||||
return '<!doctype html><!-- OASIS_LOCK_MARKER --><html lang="en"><head><meta charset="utf-8">'
|
||||
+ '<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">'
|
||||
+ '<title>Oasis</title><style>'
|
||||
+ '*{box-sizing:border-box}body{margin:0;background:#121212;color:#eee;font-family:system-ui,sans-serif;'
|
||||
+ 'min-height:100vh;display:flex;align-items:center;justify-content:center;padding:24px}'
|
||||
+ '.card{width:100%;max-width:360px;text-align:center}h1{color:#FFD700;font-size:22px;margin:0 0 8px}'
|
||||
+ 'p{color:#aaa;font-size:14px;line-height:1.4;margin:0 0 20px}'
|
||||
+ 'input{width:100%;padding:14px;margin:8px 0;border-radius:10px;border:1px solid #333;'
|
||||
+ 'background:#1c1c1c;color:#eee;font-size:16px}'
|
||||
+ 'button[type=submit]{width:100%;padding:14px;margin-top:12px;border:0;border-radius:10px;background:#FFD700;'
|
||||
+ 'color:#111;font-size:16px;font-weight:600}button[type=submit]:disabled{opacity:.5}'
|
||||
+ '#err{color:#ff6b6b;font-size:13px;min-height:18px;margin-top:8px}#msg{color:#FFD700;font-size:14px}'
|
||||
+ '#langbtn{position:fixed;top:calc(env(safe-area-inset-top,0px) + 12px);right:12px;width:42px;height:42px;'
|
||||
+ 'border-radius:50%;border:1px solid #333;background:#1c1c1c;color:#FFD700;font-size:13px;font-weight:700;'
|
||||
+ 'padding:0;z-index:10;cursor:pointer;display:flex;align-items:center;justify-content:center}'
|
||||
+ '.langmenu{position:fixed;top:calc(env(safe-area-inset-top,0px) + 60px);right:12px;background:#1c1c1c;'
|
||||
+ 'border:1px solid #333;border-radius:12px;padding:6px;z-index:11;max-height:70vh;overflow:auto;min-width:150px;'
|
||||
+ 'box-shadow:0 6px 20px rgba(0,0,0,.5)}'
|
||||
+ '.langmenu button{display:block;width:100%;text-align:left;background:transparent;color:#eee;border:0;'
|
||||
+ 'border-radius:8px;padding:11px 12px;font-size:15px;margin:0;font-weight:400;cursor:pointer}'
|
||||
+ '.langmenu button.cur{color:#FFD700;font-weight:700;background:#262626}'
|
||||
+ '[dir=rtl] #langbtn,[dir=rtl] .langmenu{right:auto;left:12px}[dir=rtl] .langmenu button{text-align:right}'
|
||||
+ '</style></head><body>'
|
||||
+ '<button id="langbtn" type="button" aria-label="Language">EN</button>'
|
||||
+ '<div id="langmenu" class="langmenu" hidden></div>'
|
||||
+ '<div class="card"><h1 id="title"></h1><p id="sub"></p>'
|
||||
+ '<form id="f"><input id="p" type="password" inputmode="text" autocomplete="' + (mode === 'setup' ? 'new-password' : 'current-password') + '" autofocus />'
|
||||
+ confirmField
|
||||
+ '<button type="submit" id="btn"></button>'
|
||||
+ '<div id="err"></div><div id="msg"></div></form></div>'
|
||||
+ '<script>'
|
||||
+ 'var MODE="' + mode + '";var I18N=' + JSON.stringify(LOCK_I18N) + ';var LANGS=' + JSON.stringify(LOCK_LANGS) + ';'
|
||||
+ 'var $=function(id){return document.getElementById(id);};'
|
||||
+ 'function getLang(){try{var s=localStorage.getItem("oasisLockLang");if(s&&I18N[s])return s;}catch(e){}return "en";}'
|
||||
+ 'var LANG=getLang();'
|
||||
+ 'function setCookie(l){try{document.cookie="language="+l+";path=/;max-age=31536000";}catch(e){}}'
|
||||
+ 'try{if(localStorage.getItem("oasisLockLang"))setCookie(LANG);}catch(e){}'
|
||||
+ 'function apply(){var t=I18N[LANG]||I18N.en;'
|
||||
+ 'document.documentElement.lang=LANG;document.documentElement.dir=(LANG==="ar")?"rtl":"ltr";'
|
||||
+ '$("title").innerHTML=(MODE==="setup")?t.t_setup:t.t_unlock;'
|
||||
+ '$("sub").innerHTML=(MODE==="setup")?t.s_setup:t.s_unlock;'
|
||||
+ '$("p").placeholder=t.ph;var c=$("c");if(c)c.placeholder=t.ph2;'
|
||||
+ '$("btn").textContent=(MODE==="setup")?t.b_setup:t.b_unlock;'
|
||||
+ '$("langbtn").textContent=LANG.toUpperCase();}'
|
||||
+ 'function buildMenu(){var m=$("langmenu");m.innerHTML="";LANGS.forEach(function(l){'
|
||||
+ 'var b=document.createElement("button");b.type="button";b.textContent=(I18N[l]&&I18N[l].name)||l;'
|
||||
+ 'if(l===LANG)b.className="cur";b.onclick=function(){LANG=l;try{localStorage.setItem("oasisLockLang",l);}catch(e){}'
|
||||
+ 'setCookie(l);apply();buildMenu();m.hidden=true;};m.appendChild(b);});}'
|
||||
+ '$("langbtn").onclick=function(){var m=$("langmenu");m.hidden=!m.hidden;};'
|
||||
+ 'document.addEventListener("click",function(e){var m=$("langmenu");if(!m.hidden&&e.target!==$("langbtn")&&!m.contains(e.target))m.hidden=true;});'
|
||||
+ 'buildMenu();apply();'
|
||||
+ 'var f=$("f"),err=$("err"),msg=$("msg");'
|
||||
+ 'f.addEventListener("submit",async function(e){e.preventDefault();var t=I18N[LANG]||I18N.en;err.textContent="";'
|
||||
+ 'var p=$("p").value,c=$("c");'
|
||||
+ 'if(MODE==="setup"){if(p.length<6){err.textContent=t.e_min;return;}if(c&&c.value!==p){err.textContent=t.e_mism;return;}}'
|
||||
+ 'var btn=$("btn");btn.disabled=true;'
|
||||
+ 'try{var r=await fetch(MODE==="setup"?"/__keylock/setup":"/__keylock/unlock",{method:"POST",'
|
||||
+ 'headers:{"Content-Type":"application/json"},body:JSON.stringify({password:p})});'
|
||||
+ 'if(r.ok){msg.textContent=t.m_unlock;waitReload();}else{err.textContent=t.e_wrong;btn.disabled=false;}}'
|
||||
+ 'catch(e2){err.textContent=t.e_pre+e2.message;btn.disabled=false;}});'
|
||||
+ 'async function waitReload(){for(var i=0;i<120;i++){try{var r=await fetch("/?_k="+Date.now(),{cache:"no-store"});'
|
||||
+ 'var t=await r.text();if(t.indexOf("OASIS_LOCK_MARKER")===-1){location.reload();return;}}catch(e){}'
|
||||
+ 'await new Promise(function(s){setTimeout(s,500);});}location.reload();}'
|
||||
+ '</script></body></html>';
|
||||
}
|
||||
|
||||
function readBody(req) {
|
||||
return new Promise((resolve) => {
|
||||
let b = '';
|
||||
req.on('data', (d) => { b += d; if (b.length > 1e5) req.destroy(); });
|
||||
req.on('end', () => { try { resolve(JSON.parse(b || '{}')); } catch (_) { resolve({}); } });
|
||||
req.on('error', () => resolve({}));
|
||||
});
|
||||
}
|
||||
|
||||
const sockets = new Set();
|
||||
const lockServer = http.createServer(async (req, res) => {
|
||||
try {
|
||||
if (req.method === 'POST' && req.url === '/__keylock/setup') {
|
||||
const { password } = await readBody(req);
|
||||
if (!password || String(password).length < 6) { res.writeHead(400, { 'Connection': 'close' }); return res.end('{"ok":false}'); }
|
||||
keylock.setPassword(configPath, password);
|
||||
const keys = keylock.loadOrCreateSecret(configPath);
|
||||
res.writeHead(200, { 'Content-Type': 'application/json', 'Connection': 'close' });
|
||||
res.end('{"ok":true}');
|
||||
return unlockAndStart(keys);
|
||||
}
|
||||
if (req.method === 'POST' && req.url === '/__keylock/unlock') {
|
||||
const { password } = await readBody(req);
|
||||
const kek = password ? keylock.verifyPassword(configPath, password) : null;
|
||||
if (!kek) { res.writeHead(401, { 'Connection': 'close' }); return res.end('{"ok":false}'); }
|
||||
const keys = keylock.loadOrCreateSecret(configPath);
|
||||
res.writeHead(200, { 'Content-Type': 'application/json', 'Connection': 'close' });
|
||||
res.end('{"ok":true}');
|
||||
return unlockAndStart(keys);
|
||||
}
|
||||
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Connection': 'close' });
|
||||
res.end(lockPage(MODE));
|
||||
} catch (e) {
|
||||
res.writeHead(500, { 'Connection': 'close' });
|
||||
res.end('{"ok":false,"error":"' + String(e && e.message).replace(/"/g, '') + '"}');
|
||||
}
|
||||
});
|
||||
lockServer.on('connection', (s) => { sockets.add(s); s.on('close', () => sockets.delete(s)); });
|
||||
lockServer.listen(3000, '127.0.0.1', () => {
|
||||
console.log('[Oasis] keylock gate escuchando en 127.0.0.1:3000 (modo: ' + MODE + ')');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@krakenslab/oasis",
|
||||
"version": "1.3.3",
|
||||
"version": "0.8.7",
|
||||
"description": "Oasis - Social Networking Utopia",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
|
@ -156,4 +156,4 @@
|
|||
"node": "^10.0.0 || >=12.0.0"
|
||||
},
|
||||
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,105 +0,0 @@
|
|||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const crypto = require('crypto');
|
||||
const { spawn } = require('child_process');
|
||||
const axiosMod = require('../server/node_modules/axios');
|
||||
const axios = axiosMod.default || axiosMod;
|
||||
|
||||
const net = require('net');
|
||||
const BASE_PORT = Number(process.env.OASIS_AI_PORT) || 4001;
|
||||
let PORT = BASE_PORT;
|
||||
|
||||
const portIsFree = (port) => new Promise((resolve) => {
|
||||
const probe = net.createServer();
|
||||
probe.once('error', () => resolve(false));
|
||||
probe.once('listening', () => probe.close(() => resolve(true)));
|
||||
probe.listen(port, '127.0.0.1');
|
||||
});
|
||||
|
||||
const pickPort = async () => {
|
||||
for (let p = BASE_PORT; p < BASE_PORT + 20; p++) {
|
||||
if (await portIsFree(p)) return p;
|
||||
}
|
||||
return BASE_PORT;
|
||||
};
|
||||
const MODEL_FILE = 'oasis-42-1-chat.Q4_K_M.gguf';
|
||||
const MODEL_PATH = process.env.OASIS_AI_MODEL || path.join(__dirname, MODEL_FILE);
|
||||
const ASK_TIMEOUT_MS = 300000;
|
||||
const DEBUG = process.env.OASIS_DEBUG === '1' || process.env.OASIS_DEBUG === 'true';
|
||||
const STATUS_TIMEOUT_MS = 3000;
|
||||
|
||||
const token = crypto.randomBytes(24).toString('hex');
|
||||
let child = null;
|
||||
let started = false;
|
||||
|
||||
const isModelInstalled = () => { try { return fs.existsSync(MODEL_PATH); } catch (_) { return false; } };
|
||||
|
||||
let starting = null;
|
||||
const start = () => {
|
||||
if (started) return starting || Promise.resolve();
|
||||
started = true;
|
||||
if (process.env.OASIS_TEST) return Promise.resolve();
|
||||
starting = pickPort().then((port) => { PORT = port; spawnService(); }).catch(() => { spawnService(); }).finally(() => { starting = null; });
|
||||
return starting;
|
||||
};
|
||||
|
||||
const spawnService = () => {
|
||||
try {
|
||||
child = spawn(process.execPath, [path.join(__dirname, 'ai_service.mjs')], {
|
||||
stdio: ['ignore', 'ignore', DEBUG ? 'inherit' : 'ignore'],
|
||||
env: { ...process.env, OASIS_AI_TOKEN: token, OASIS_AI_PORT: String(PORT), OASIS_AI_PARENT_PID: String(process.pid), OASIS_AI_MODEL: MODEL_PATH }
|
||||
});
|
||||
child.on('exit', () => { child = null; started = false; });
|
||||
} catch (_) { child = null; started = false; }
|
||||
};
|
||||
|
||||
const stop = () => {
|
||||
if (!child) return;
|
||||
try { child.kill('SIGTERM'); } catch (_) {}
|
||||
child = null;
|
||||
started = false;
|
||||
};
|
||||
|
||||
const headers = () => ({ 'x-oasis-ai-token': token });
|
||||
|
||||
const status = async () => {
|
||||
if (!isModelInstalled()) return { installed: false, ready: false, loading: false, error: 'model_missing' };
|
||||
await start();
|
||||
try {
|
||||
const r = await axios.get(`http://127.0.0.1:${PORT}/status`, { headers: headers(), timeout: STATUS_TIMEOUT_MS, proxy: false });
|
||||
return { installed: true, ...(r.data || {}) };
|
||||
} catch (_) {
|
||||
return { installed: true, ready: false, loading: true, error: null };
|
||||
}
|
||||
};
|
||||
|
||||
const CONNECT_RETRIES = 300;
|
||||
const CONNECT_RETRY_MS = 1000;
|
||||
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
||||
|
||||
const ask = async ({ system = '', history = [], context = [], input = '', lang = 'en', maxTokens } = {}) => {
|
||||
if (!isModelInstalled()) throw new Error('model_missing');
|
||||
await start();
|
||||
let lastErr = null;
|
||||
for (let attempt = 0; attempt < CONNECT_RETRIES; attempt++) {
|
||||
try {
|
||||
const r = await axios.post(`http://127.0.0.1:${PORT}/ai`, { system, history, context, input, lang, maxTokens }, { headers: headers(), timeout: ASK_TIMEOUT_MS, proxy: false });
|
||||
if (!r.data || typeof r.data.answer !== 'string') throw new Error('bad_answer');
|
||||
return r.data.answer;
|
||||
} catch (e) {
|
||||
lastErr = e;
|
||||
const httpStatus = e && e.response && e.response.status;
|
||||
const retryable = (e && e.code === 'ECONNREFUSED') || httpStatus === 503 || httpStatus === 403;
|
||||
if (!retryable) break;
|
||||
await sleep(CONNECT_RETRY_MS);
|
||||
}
|
||||
}
|
||||
throw lastErr || new Error('not_ready');
|
||||
};
|
||||
|
||||
process.on('exit', stop);
|
||||
for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
|
||||
process.on(sig, () => { stop(); process.exit(0); });
|
||||
}
|
||||
|
||||
module.exports = { start, stop, status, ask, isModelInstalled, MODEL_PATH, MODEL_FILE, port: () => PORT };
|
||||
|
|
@ -1,168 +0,0 @@
|
|||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import http from 'http';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { getLlama, LlamaChatSession } from './node_modules/node-llama-cpp/dist/index.js';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const PORT = Number(process.env.OASIS_AI_PORT) || 4001;
|
||||
const TOKEN = String(process.env.OASIS_AI_TOKEN || '');
|
||||
const PARENT_PID = Number(process.env.OASIS_AI_PARENT_PID) || 0;
|
||||
const MODEL_FILE = 'oasis-42-1-chat.Q4_K_M.gguf';
|
||||
const MODEL_PATH = process.env.OASIS_AI_MODEL || path.join(__dirname, MODEL_FILE);
|
||||
const GENERATION_TIMEOUT_MS = 120000;
|
||||
const MAX_TOKENS_DEFAULT = 400;
|
||||
const MAX_INPUT_CHARS = 5000;
|
||||
const MAX_CONTEXT_CHARS = 6000;
|
||||
|
||||
const state = { loading: false, ready: false, error: null, model: null, contextSize: 0, threads: 0 };
|
||||
let llama, model, context;
|
||||
let queue = Promise.resolve();
|
||||
let loadingPromise = null;
|
||||
|
||||
const pickThreads = () => Math.max(1, Math.min(os.cpus().length - 1, 6));
|
||||
const pickContextSize = () => {
|
||||
const gb = os.totalmem() / (1024 ** 3);
|
||||
if (gb < 6) return 2048;
|
||||
if (gb < 12) return 4096;
|
||||
return 8192;
|
||||
};
|
||||
|
||||
async function initModel() {
|
||||
if (state.ready) return;
|
||||
if (loadingPromise) return loadingPromise;
|
||||
state.loading = true;
|
||||
loadingPromise = loadModel().finally(() => { loadingPromise = null; });
|
||||
return loadingPromise;
|
||||
}
|
||||
|
||||
const DEBUG = process.env.OASIS_DEBUG === '1' || process.env.OASIS_DEBUG === 'true';
|
||||
const log = (msg) => { if (!DEBUG) return; try { process.stderr.write(`[ai-service] ${msg}\n`); } catch (_) {} };
|
||||
|
||||
async function loadModel() {
|
||||
const t0 = Date.now();
|
||||
try {
|
||||
if (!fs.existsSync(MODEL_PATH)) throw new Error(`model_missing:${MODEL_PATH}`);
|
||||
log(`loading ${path.basename(MODEL_PATH)}`);
|
||||
llama = await getLlama({ gpu: false });
|
||||
log(`llama ready (${Date.now() - t0} ms)`);
|
||||
model = await llama.loadModel({ modelPath: MODEL_PATH });
|
||||
log(`model loaded (${Date.now() - t0} ms)`);
|
||||
state.threads = pickThreads();
|
||||
state.contextSize = pickContextSize();
|
||||
context = await model.createContext({ contextSize: state.contextSize, threads: state.threads });
|
||||
log(`context ready: ${state.contextSize} tokens, ${state.threads} threads (${Date.now() - t0} ms)`);
|
||||
state.model = path.basename(MODEL_PATH);
|
||||
state.ready = true;
|
||||
state.error = null;
|
||||
} catch (e) {
|
||||
state.error = String((e && e.message) || e);
|
||||
log(`load failed: ${state.error}`);
|
||||
} finally {
|
||||
state.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
const clip = (s, n) => String(s == null ? '' : s).slice(0, n);
|
||||
|
||||
const buildHistory = ({ system, history, context: ctxLines }) => {
|
||||
const items = [];
|
||||
const sys = [clip(system, 2000)];
|
||||
const lines = Array.isArray(ctxLines) ? ctxLines.map(x => clip(x, 800)).filter(Boolean) : [];
|
||||
if (lines.length) {
|
||||
let block = '';
|
||||
for (const l of lines) { if ((block + l).length > MAX_CONTEXT_CHARS) break; block += `- ${l}\n`; }
|
||||
sys.push(`Knowledge from the Oasis network (use it when relevant, never invent beyond it):\n${block}`);
|
||||
}
|
||||
items.push({ type: 'system', text: sys.join('\n\n') });
|
||||
for (const turn of (Array.isArray(history) ? history : []).slice(-8)) {
|
||||
if (!turn || typeof turn.text !== 'string' || !turn.text.trim()) continue;
|
||||
if (turn.type === 'user') items.push({ type: 'user', text: clip(turn.text, MAX_INPUT_CHARS) });
|
||||
else if (turn.type === 'model') items.push({ type: 'model', response: [clip(turn.text, MAX_INPUT_CHARS)] });
|
||||
}
|
||||
return items;
|
||||
};
|
||||
|
||||
async function answer(body) {
|
||||
await initModel();
|
||||
if (!state.ready) throw new Error(state.error || 'not_ready');
|
||||
const input = clip(body.input, MAX_INPUT_CHARS).trim();
|
||||
if (!input) throw new Error('empty_input');
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), GENERATION_TIMEOUT_MS);
|
||||
const sequence = context.getSequence();
|
||||
const session = new LlamaChatSession({ contextSequence: sequence });
|
||||
try {
|
||||
session.setChatHistory(buildHistory(body));
|
||||
const text = await session.prompt(input, {
|
||||
maxTokens: Math.max(32, Math.min(1024, Number(body.maxTokens) || MAX_TOKENS_DEFAULT)),
|
||||
temperature: 0.6,
|
||||
signal: controller.signal,
|
||||
stopOnAbortSignal: true
|
||||
});
|
||||
return String(text || '').trim();
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
try { session.dispose(); } catch (_) {}
|
||||
try { sequence.dispose(); } catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
const readJson = (req) => new Promise((resolve, reject) => {
|
||||
let raw = '';
|
||||
req.on('data', (c) => { raw += c; if (raw.length > 200000) { reject(new Error('body_too_large')); req.destroy(); } });
|
||||
req.on('end', () => { try { resolve(raw ? JSON.parse(raw) : {}); } catch (e) { reject(e); } });
|
||||
req.on('error', reject);
|
||||
});
|
||||
|
||||
const send = (res, status, obj) => {
|
||||
res.writeHead(status, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(obj));
|
||||
};
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
if (TOKEN && req.headers['x-oasis-ai-token'] !== TOKEN) return send(res, 403, { error: 'forbidden' });
|
||||
if (req.method === 'GET' && req.url === '/status') {
|
||||
if (!state.ready && !state.loading && !state.error) initModel();
|
||||
return send(res, 200, { ...state });
|
||||
}
|
||||
if (req.method === 'POST' && req.url === '/ai') {
|
||||
let body;
|
||||
try { body = await readJson(req); } catch (e) { return send(res, 400, { error: 'bad_request' }); }
|
||||
const job = queue.then(() => answer(body));
|
||||
queue = job.catch(() => {});
|
||||
try {
|
||||
const text = await job;
|
||||
return send(res, 200, { answer: text });
|
||||
} catch (e) {
|
||||
const msg = String((e && e.message) || e);
|
||||
const status = msg.startsWith('model_missing') ? 503 : (msg === 'not_ready' ? 503 : 500);
|
||||
log(`answer failed: ${msg}`);
|
||||
return send(res, status, { error: msg, ...state });
|
||||
}
|
||||
}
|
||||
send(res, 404, { error: 'not_found' });
|
||||
});
|
||||
|
||||
let listenAttempts = 0;
|
||||
server.on('error', (e) => {
|
||||
if (e && e.code === 'EADDRINUSE' && listenAttempts < 20) {
|
||||
listenAttempts += 1;
|
||||
setTimeout(() => server.listen(PORT, '127.0.0.1'), 1000);
|
||||
return;
|
||||
}
|
||||
process.exit(1);
|
||||
});
|
||||
server.listen(PORT, '127.0.0.1', () => { initModel(); });
|
||||
|
||||
const shutdown = () => { try { server.close(); } catch (_) {} process.exit(0); };
|
||||
process.on('SIGTERM', shutdown);
|
||||
process.on('SIGINT', shutdown);
|
||||
if (PARENT_PID > 0) {
|
||||
setInterval(() => {
|
||||
try { process.kill(PARENT_PID, 0); } catch (_) { shutdown(); }
|
||||
}, 5000).unref();
|
||||
}
|
||||
|
|
@ -1,238 +0,0 @@
|
|||
const { readTyped } = require('../models/typed_log')
|
||||
const { getConfig } = require('../configs/config-manager.js')
|
||||
|
||||
const logLimit = getConfig().ssbLogStream?.limit || 1000
|
||||
|
||||
let cooler = null
|
||||
let ssb = null
|
||||
let opening = null
|
||||
|
||||
function getCooler() {
|
||||
let ssbPath = null
|
||||
try { ssbPath = require.resolve('../server/SSB_server.js') } catch {}
|
||||
if (ssbPath && require.cache[ssbPath]) {
|
||||
if (!cooler) {
|
||||
const gui = require('../client/gui.js')
|
||||
cooler = gui({ offline: false })
|
||||
}
|
||||
return cooler
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function useCooler(c) {
|
||||
cooler = c || null
|
||||
ssb = null
|
||||
opening = null
|
||||
}
|
||||
|
||||
const DEBUG = process.env.OASIS_DEBUG === '1' || process.env.OASIS_DEBUG === 'true'
|
||||
const debug = (msg) => { if (DEBUG) console.error(`[ai] ${msg}`) }
|
||||
|
||||
async function openSsb() {
|
||||
const c = cooler || getCooler()
|
||||
if (!c) return null
|
||||
if (ssb && ssb.closed === false) return ssb
|
||||
if (!opening) {
|
||||
debug('opening the SSB connection for the network knowledge')
|
||||
opening = c.open().then(x => { debug('SSB connection open'); return (ssb = x) }).finally(() => { opening = null })
|
||||
}
|
||||
await opening
|
||||
return ssb
|
||||
}
|
||||
|
||||
const clip = (s, n) => String(s || '').slice(0, n)
|
||||
const squash = s => String(s || '').replace(/\s+/g, ' ').trim()
|
||||
const compact = s => squash(clip(s, 160))
|
||||
const normalize = s => String(s || '').toLowerCase().replace(/\s+/g, ' ').replace(/[^\w\u0080-\uFFFF\s]+/g, '').trim()
|
||||
|
||||
function fieldsForSnippet(type, c) {
|
||||
if (type === 'aiExchange') return [c?.question, clip(squash(c?.answer || ''), 120)]
|
||||
return []
|
||||
}
|
||||
|
||||
async function publishExchange({ q, a, ctx = [], tokens = {}, lang = '', tags = [], rating = 0 }) {
|
||||
const s = await openSsb()
|
||||
if (!s) return null
|
||||
const safeLang = String(lang || '').trim().slice(0, 8).toLowerCase()
|
||||
const safeTags = Array.isArray(tags)
|
||||
? Array.from(new Set(tags.map(t => String(t || '').trim().slice(0, 32)).filter(Boolean))).slice(0, 10)
|
||||
: []
|
||||
const safeRating = Math.max(0, Math.min(5, Math.round(Number(rating) || 0)))
|
||||
const content = {
|
||||
type: 'aiExchange',
|
||||
question: clip(String(q || ''), 2000),
|
||||
answer: clip(String(a || ''), 5000),
|
||||
ctx: ctx.slice(0, 12).map(x => clip(String(x || ''), 800)),
|
||||
timestamp: Date.now()
|
||||
}
|
||||
if (safeLang) content.lang = safeLang
|
||||
if (safeTags.length) content.tags = safeTags
|
||||
if (safeRating > 0) content.rating = safeRating
|
||||
return new Promise((resolve, reject) => {
|
||||
s.publish(content, (err, res) => err ? reject(err) : resolve(res))
|
||||
})
|
||||
}
|
||||
|
||||
async function publishExchangeVote({ targetId, helpful = true }) {
|
||||
const s = await openSsb()
|
||||
if (!s || !targetId) return null
|
||||
const content = {
|
||||
type: 'aiExchangeVote',
|
||||
target: String(targetId),
|
||||
helpful: !!helpful,
|
||||
timestamp: Date.now()
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
s.publish(content, (err, res) => err ? reject(err) : resolve(res))
|
||||
})
|
||||
}
|
||||
|
||||
async function buildContext(maxItems = 100) {
|
||||
const s = await openSsb()
|
||||
if (!s) return ''
|
||||
const msgs = (await readTyped(s, ['aiExchange'], { limit: logLimit }).catch(() => null) || []).reverse()
|
||||
const lines = []
|
||||
for (const { value } of msgs) {
|
||||
const c = value && value.content || {}
|
||||
if (c.type !== 'aiExchange') continue
|
||||
const d = new Date(value.timestamp || 0).toISOString().slice(0, 10)
|
||||
const q = compact(c.question)
|
||||
const a = compact(c.answer)
|
||||
lines.push(`[${d}] (AIExchange) Q: ${q} | A: ${a}`)
|
||||
if (lines.length >= maxItems) break
|
||||
}
|
||||
if (lines.length === 0) return ''
|
||||
return `## AIEXCHANGE\n\n${lines.join('\n')}`
|
||||
}
|
||||
|
||||
const fs = require('fs')
|
||||
const VECTORS_FILE = 'AI-vectors.json'
|
||||
const VECTORS_MAX = 4000
|
||||
const RATING_WEIGHT = 0.03
|
||||
const VOTE_WEIGHT = 0.02
|
||||
const CONTEXT_MIN_SCORE = 0.35
|
||||
const DIRECT_ANSWER_MIN = 0.92
|
||||
|
||||
const vectorsPath = () => { try { return require('../configs/state-manager').statePath(VECTORS_FILE) } catch (_) { return null } }
|
||||
const readVectors = () => { try { const j = JSON.parse(fs.readFileSync(vectorsPath(), 'utf8')); return j && typeof j === 'object' ? j : {} } catch (_) { return {} } }
|
||||
const writeVectors = (map) => { try { const p = vectorsPath(); if (p) fs.writeFileSync(p, JSON.stringify(map)) } catch (_) {} }
|
||||
|
||||
const exchangeText = (c) => clip(squash(c.question), 300)
|
||||
|
||||
async function trustedAuthors(s) {
|
||||
const me = s.id
|
||||
const msgs = await readTyped(s, ['contact'], { limit: logLimit }).catch(() => null) || []
|
||||
const latest = new Map()
|
||||
for (const m of msgs) {
|
||||
const v = m && m.value || {}
|
||||
const c = v.content || {}
|
||||
if (v.author !== me || typeof c.contact !== 'string') continue
|
||||
const ts = Number(v.timestamp) || 0
|
||||
const prev = latest.get(c.contact)
|
||||
if (prev && prev.ts > ts) continue
|
||||
latest.set(c.contact, { ts, on: c.following === true && c.blocking !== true })
|
||||
}
|
||||
const out = new Set([me])
|
||||
for (const [id, st] of latest) if (st.on) out.add(id)
|
||||
return out
|
||||
}
|
||||
|
||||
async function listExchanges() {
|
||||
const s = await openSsb()
|
||||
if (!s) return { exchanges: [], votes: new Map() }
|
||||
const msgs = await readTyped(s, ['aiExchange', 'aiExchangeVote'], { limit: logLimit }).catch(() => null) || []
|
||||
const trusted = await trustedAuthors(s)
|
||||
const exchanges = []
|
||||
const votes = new Map()
|
||||
for (const m of msgs) {
|
||||
const c = m && m.value && m.value.content || {}
|
||||
if (!trusted.has(m.value.author)) continue
|
||||
if (c.type === 'aiExchange' && c.question && c.answer) {
|
||||
exchanges.push({ key: m.key, author: m.value.author, ts: m.value.timestamp || m.timestamp || 0, question: String(c.question), answer: String(c.answer), rating: Number(c.rating) || 0, tags: Array.isArray(c.tags) ? c.tags : [], lang: c.lang || '' })
|
||||
} else if (c.type === 'aiExchangeVote' && c.target) {
|
||||
const k = String(c.target)
|
||||
const per = votes.get(k) || new Map()
|
||||
per.set(m.value.author, c.helpful !== false ? 1 : -1)
|
||||
votes.set(k, per)
|
||||
}
|
||||
}
|
||||
return { exchanges, votes }
|
||||
}
|
||||
|
||||
const netVotes = (votes, key) => { const per = votes.get(key); if (!per) return 0; let n = 0; for (const v of per.values()) n += v; return n }
|
||||
|
||||
async function rankExchanges(question, { embed, cosine, k = 5, excludeAuthors = null } = {}) {
|
||||
const listed = await listExchanges()
|
||||
const votes = listed.votes
|
||||
const exchanges = excludeAuthors instanceof Set && excludeAuthors.size
|
||||
? listed.exchanges.filter(ex => !excludeAuthors.has(String(ex.author)))
|
||||
: listed.exchanges
|
||||
if (!exchanges.length) return []
|
||||
const scored = []
|
||||
if (typeof embed === 'function' && typeof cosine === 'function') {
|
||||
const qv = await embed(question).catch(() => null)
|
||||
if (qv) {
|
||||
const cache = readVectors()
|
||||
let dirty = false
|
||||
const newest = exchanges.slice().sort((a, b) => b.ts - a.ts).slice(0, VECTORS_MAX)
|
||||
for (const ex of newest) {
|
||||
let v = cache[ex.key]
|
||||
if (!Array.isArray(v)) {
|
||||
v = await embed(exchangeText(ex)).catch(() => null)
|
||||
if (!v) continue
|
||||
cache[ex.key] = v
|
||||
dirty = true
|
||||
}
|
||||
const sim = cosine(qv, v)
|
||||
scored.push({ ...ex, similarity: sim, votes: netVotes(votes, ex.key), score: sim + RATING_WEIGHT * ex.rating + VOTE_WEIGHT * Math.max(-5, Math.min(5, netVotes(votes, ex.key))) })
|
||||
}
|
||||
if (dirty) {
|
||||
const keep = new Set(newest.map(x => x.key))
|
||||
for (const key of Object.keys(cache)) if (!keep.has(key)) delete cache[key]
|
||||
writeVectors(cache)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!scored.length) {
|
||||
const want = normalize(question)
|
||||
const terms = want.split(' ').filter(t => t.length > 2)
|
||||
for (const ex of exchanges) {
|
||||
const text = normalize(ex.question + ' ' + ex.answer)
|
||||
const hits = terms.filter(t => text.includes(t)).length
|
||||
if (!hits) continue
|
||||
const sim = normalize(ex.question) === want ? 1 : hits / Math.max(1, terms.length)
|
||||
scored.push({ ...ex, similarity: sim, votes: netVotes(votes, ex.key), score: sim + RATING_WEIGHT * ex.rating + VOTE_WEIGHT * Math.max(-5, Math.min(5, netVotes(votes, ex.key))) })
|
||||
}
|
||||
}
|
||||
scored.sort((a, b) => b.score - a.score)
|
||||
return scored.filter(x => x.similarity >= CONTEXT_MIN_SCORE).slice(0, k)
|
||||
}
|
||||
|
||||
async function rankContext(question, opts = {}) {
|
||||
const top = await rankExchanges(question, opts)
|
||||
return top.map(ex => `Q: ${compact(ex.question)} | A: ${clip(squash(ex.answer), 400)}`)
|
||||
}
|
||||
|
||||
async function getBestTrainedAnswer(question, opts = {}) {
|
||||
const top = await rankExchanges(question, { ...opts, k: 1 })
|
||||
const best = top[0]
|
||||
if (!best || best.similarity < DIRECT_ANSWER_MIN || best.votes < 0) return null
|
||||
return { answer: String(best.answer || '').trim(), ctx: [`Q: ${compact(best.question)}`], key: best.key }
|
||||
}
|
||||
|
||||
async function exportFineTuning({ system = '' } = {}) {
|
||||
const { exchanges, votes } = await listExchanges()
|
||||
const lines = []
|
||||
for (const ex of exchanges.sort((a, b) => a.ts - b.ts)) {
|
||||
if (netVotes(votes, ex.key) < 0) continue
|
||||
const messages = []
|
||||
if (system) messages.push({ role: 'system', content: system })
|
||||
messages.push({ role: 'user', content: ex.question })
|
||||
messages.push({ role: 'assistant', content: ex.answer })
|
||||
lines.push(JSON.stringify({ messages, meta: { key: ex.key, author: ex.author, lang: ex.lang, tags: ex.tags, rating: ex.rating, helpful: netVotes(votes, ex.key), ts: ex.ts } }))
|
||||
}
|
||||
return lines.join('\n') + (lines.length ? '\n' : '')
|
||||
}
|
||||
|
||||
module.exports = { fieldsForSnippet, buildContext, clip, publishExchange, publishExchangeVote, getBestTrainedAnswer, rankContext, rankExchanges, listExchanges, exportFineTuning, useCooler, DIRECT_ANSWER_MIN, CONTEXT_MIN_SCORE }
|
||||
|
|
@ -1,138 +0,0 @@
|
|||
const path = require('path')
|
||||
const fs = require('fs')
|
||||
const { fork } = require('child_process')
|
||||
|
||||
const MODEL_DIR = path.join(__dirname, 'embeddings')
|
||||
const MODEL_FILE = path.join(MODEL_DIR, 'onnx', 'model_quantized.onnx')
|
||||
const EMBED_TIMEOUT_MS = 30000
|
||||
|
||||
let worker = null
|
||||
let workerReady = false
|
||||
let permanentFail = false
|
||||
let unavailableReason = null
|
||||
let seq = 0
|
||||
const pending = new Map()
|
||||
|
||||
const isInstalled = () => {
|
||||
try {
|
||||
return fs.existsSync(MODEL_FILE)
|
||||
} catch (_) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const dot = (a, b) => {
|
||||
let s = 0
|
||||
for (let i = 0; i < a.length; i++) s += a[i] * b[i]
|
||||
return s
|
||||
}
|
||||
|
||||
const norm = (a) => Math.sqrt(dot(a, a)) || 1
|
||||
|
||||
const cosine = (a, b) => dot(a, b) / (norm(a) * norm(b))
|
||||
|
||||
const settlePending = () => {
|
||||
for (const p of pending.values()) p.resolve(null)
|
||||
pending.clear()
|
||||
}
|
||||
|
||||
const ensureWorker = () => {
|
||||
if (worker || permanentFail) return
|
||||
const child = fork(__filename, ['--embed-worker'], { silent: true })
|
||||
if (child.stdout) child.stdout.resume()
|
||||
if (child.stderr) child.stderr.resume()
|
||||
child.on('message', (msg) => {
|
||||
const p = pending.get(msg && msg.id)
|
||||
if (!p) return
|
||||
pending.delete(msg.id)
|
||||
workerReady = true
|
||||
p.resolve(Array.isArray(msg.vec) ? msg.vec : null)
|
||||
})
|
||||
child.on('error', () => {})
|
||||
child.on('exit', () => {
|
||||
settlePending()
|
||||
if (worker === child) {
|
||||
worker = null
|
||||
if (!workerReady) {
|
||||
permanentFail = true
|
||||
unavailableReason = 'embedder_native_crash'
|
||||
}
|
||||
workerReady = false
|
||||
}
|
||||
})
|
||||
worker = child
|
||||
}
|
||||
|
||||
const embed = async (text, { timeoutMs = EMBED_TIMEOUT_MS } = {}) => {
|
||||
if (!isInstalled() || permanentFail) return null
|
||||
ensureWorker()
|
||||
const child = worker
|
||||
if (!child) return null
|
||||
const id = ++seq
|
||||
return new Promise((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
if (pending.has(id)) {
|
||||
pending.delete(id)
|
||||
try { child.kill() } catch (_) {}
|
||||
resolve(null)
|
||||
}
|
||||
}, Math.max(1000, Number(timeoutMs) || EMBED_TIMEOUT_MS))
|
||||
pending.set(id, { resolve: (v) => { clearTimeout(timer); resolve(v) } })
|
||||
try {
|
||||
child.send({ id, text: String(text || '').trim() })
|
||||
} catch (_) {
|
||||
pending.delete(id)
|
||||
clearTimeout(timer)
|
||||
resolve(null)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const status = () => ({
|
||||
installed: isInstalled(),
|
||||
loaded: workerReady,
|
||||
unavailableReason
|
||||
})
|
||||
|
||||
module.exports = { embed, cosine, isInstalled, status, MODEL_DIR, MODEL_FILE }
|
||||
|
||||
if (process.argv[2] === '--embed-worker') {
|
||||
let pipelinePromise = null
|
||||
const ensurePipeline = async () => {
|
||||
if (pipelinePromise) return pipelinePromise
|
||||
if (!isInstalled()) return null
|
||||
pipelinePromise = (async () => {
|
||||
let mod
|
||||
try {
|
||||
const url = require('url')
|
||||
const transformersPath = path.join(__dirname, 'node_modules', '@xenova', 'transformers', 'src', 'transformers.js')
|
||||
mod = await import(url.pathToFileURL(transformersPath).href)
|
||||
} catch (_) {
|
||||
return null
|
||||
}
|
||||
const { pipeline, env } = mod
|
||||
env.allowRemoteModels = false
|
||||
env.localModelPath = path.join(__dirname)
|
||||
env.cacheDir = path.join(__dirname, '.cache')
|
||||
try {
|
||||
return await pipeline('feature-extraction', 'embeddings', { quantized: true })
|
||||
} catch (_) {
|
||||
return null
|
||||
}
|
||||
})()
|
||||
const fe = await pipelinePromise
|
||||
if (!fe) pipelinePromise = null
|
||||
return fe
|
||||
}
|
||||
process.on('message', async (msg) => {
|
||||
let vec = null
|
||||
try {
|
||||
const fe = await ensurePipeline()
|
||||
if (fe) {
|
||||
const out = await fe(String((msg && msg.text) || ''), { pooling: 'mean', normalize: true })
|
||||
vec = Array.from(out.data)
|
||||
}
|
||||
} catch (_) {}
|
||||
try { process.send({ id: msg && msg.id, vec }) } catch (_) {}
|
||||
})
|
||||
}
|
||||
|
|
@ -1,397 +0,0 @@
|
|||
const INTENTS = [
|
||||
{ key: 'funds', description: 'my balance, how much ECO do I have, my wallet funds, my ECOin address', keywords: ['balance', 'saldo', 'funds', 'fondos', 'wallet', 'cartera', 'address', 'dirección', 'my ecoin', 'mi ecoin', 'fons', 'moneder', 'carteira', 'adreça', 'enderezo', 'el meu ecoin', 'o meu ecoin'] },
|
||||
{ key: 'exchange', description: 'the value of one ECO, ECO price, ECOin exchange rate, how much is an ECO worth, ECO per hour, supply and inflation of ECOin', keywords: ['value of', 'valor de', 'eco value', 'ecoin value', 'valor del eco', 'price', 'precio', 'worth', 'vale un eco', 'cuánto vale', 'exchange', 'cotización', 'cambio', 'supply', 'suministro', 'inflation', 'inflación', 'eco/h', '1 eco', 'un eco', 'preu', 'prezo', 'quant val', 'canto vale', 'cotització', 'canvi', 'oferta total', 'inflació'] },
|
||||
{ key: 'ubi', description: 'my UBI, universal basic income, can I claim the UBI, when did I claim, how much UBI did I receive', keywords: ['ubi', 'rbu', 'basic income', 'renta básica', 'claim', 'reclamar', 'cobrar', 'renda bàsica', 'renda básica'] },
|
||||
{ key: 'karma', description: 'my karma score, my taxes, eco tax, arch tax, engagement score', keywords: ['karma', 'tax', 'impuesto', 'engagement', 'impost', 'imposto', 'taxa eco', 'taxa arch'] },
|
||||
{ key: 'agenda', description: 'my agenda, what do I have coming up, upcoming dates, next days, schedule', keywords: ['agenda', 'schedule', 'próximo', 'upcoming', 'coming', 'esta semana', 'this week', 'calendar', 'calendario', 'aquesta setmana', 'propers dies', 'calendari', 'axenda', 'próximos días'] },
|
||||
{ key: 'events', description: 'upcoming events, next event, events near me, what events are there', keywords: ['event', 'evento', 'esdeveniment'] },
|
||||
{ key: 'tasks', description: 'my open tasks, pending tasks, to-do, what do I have to do', keywords: ['task', 'tarea', 'to-do', 'todo', 'pendiente', 'tasca', 'tarefa', 'pendent', 'tasques'] },
|
||||
{ key: 'transfers', description: 'pending transfers, contracts waiting for my confirmation, payments to confirm', keywords: ['transfer', 'transferencia', 'contract', 'contrato', 'confirm', 'confirmar', 'payment', 'pago', 'contracte', 'pagament', 'pagamento'] },
|
||||
{ key: 'relationships', description: 'who follows me, who do I follow, my followers, my mutual contacts, friends', keywords: ['follow', 'sigue', 'seguidor', 'follower', 'mutual', 'friend', 'amigo', 'contacto', 'segueix', 'segue', 'amic', 'contacte', 'mutus'] },
|
||||
{ key: 'inbox', description: 'my inbox, unread messages, private messages, notifications, mentions', keywords: ['inbox', 'unread', 'no leído', 'mensaje', 'message', 'pm', 'notification', 'notificación', 'mention', 'mención', 'no llegit', 'non lido', 'missatge', 'mensaxe', 'notificació', 'bústia', 'buzón', 'caixa de entrada', 'menció'] },
|
||||
{ key: 'government', description: 'current government, parliament, who rules, governance cycle, LARP house', keywords: ['government', 'gobierno', 'parliament', 'parlamento', 'rules', 'gobierna', 'larp', 'house', 'casa', 'govern', 'goberno', 'parlament', 'governa', 'goberna'] },
|
||||
{ key: 'emergencies', description: 'active emergencies, alerts, is there any emergency', keywords: ['emergency', 'emergencia', 'alert', 'alerta', 'emergència', 'emerxencia', 'emergències'] },
|
||||
{ key: 'jobs', description: 'open job offers, jobs for me, work, salary', keywords: ['job', 'trabajo', 'empleo', 'offer', 'oferta', 'salary', 'salario', 'vacante', 'feina', 'treball', 'ocupació', 'traballo', 'emprego', 'salari', 'feines'] },
|
||||
{ key: 'market', description: 'items for sale in the market, second hand, auctions, what can I buy', keywords: ['market', 'mercado', 'buy', 'comprar', 'vender', 'item', 'auction', 'subasta', 'mercat', 'vendre', 'subhasta', 'poxa', 'subhastes'] },
|
||||
{ key: 'tribes', description: 'my tribes, which tribes am I in, tribe members', keywords: ['tribe', 'tribu', 'tribo'] },
|
||||
{ key: 'network', description: 'network status, inhabitants count, peers online, last sync, population', keywords: ['network', 'red', 'peer', 'inhabitant', 'habitante', 'population', 'población', 'online', 'sync', 'sincron', 'xarxa', 'rede', 'habitant', 'població', 'poboación'] },
|
||||
{ key: 'me', description: 'who am I, my identity, my id, my name, my profile, oasis version', keywords: ['who am i', 'quién soy', 'my id', 'mi id', 'identity', 'identidad', 'profile', 'perfil', 'version', 'versión', 'qui sóc', 'quen son', 'el meu id', 'o meu id', 'identitat', 'versió'] },
|
||||
{ key: 'shops', description: 'products in the shops, what do the shops sell, shop catalogue, buy a product', keywords: ['shop', 'tienda', 'product', 'producto', 'catálogo', 'catalog', 'botiga', 'tenda', 'producte', 'produto', 'catàleg', 'botigues'] },
|
||||
{ key: 'school', description: 'courses in the school, lessons, my courses, enrol, learn, certificates', keywords: ['school', 'escuela', 'course', 'curso', 'lesson', 'lección', 'learn', 'aprender', 'certificate', 'certificado', 'student', 'estudiante', 'escola', ' curs', 'lliçó', 'aprendre', 'certificat', 'estudiant', 'estudante'] },
|
||||
{ key: 'projects', description: 'crowdfunding projects, projects looking for funding, pledges, project goals', keywords: ['project', 'proyecto', 'crowdfunding', 'pledge', 'aportación', 'financiar', 'funding', 'projecte', 'proxecto', 'aportació', 'achega', 'finançar'] },
|
||||
{ key: 'housing', description: 'housing, places offered, rooms, homes, rent a place, accommodation', keywords: ['housing', 'vivienda', 'room', 'habitación', 'rent', 'alquil', 'accommodation', 'alojamiento', 'habitatge', 'vivenda', 'habitació', 'lloguer', 'aluguer', 'allotjament', 'aloxamento'] },
|
||||
{ key: 'industry', description: 'industry facilities, factories, builds, production, what is being built', keywords: ['industry', 'industria', 'facility', 'fábrica', 'factory', 'build', 'construir', 'production', 'producción', 'indústria', 'fàbrica', 'producció', 'produción'] },
|
||||
{ key: 'campaigns', description: 'campaigns, petitions, signatures, collective demands', keywords: ['campaign', 'campaña', 'petition', 'petición', 'signature', 'firma', 'demand', 'campanya', 'petició', 'signatura', 'sinatura', 'campanyes', 'signatures'] },
|
||||
{ key: 'logistics', description: 'logistics routes, deliveries, transport, shipping, packages, bookings', keywords: ['logistic', 'logística', 'route', 'ruta', 'delivery', 'entrega', 'transport', 'envío', 'shipping', 'package', 'paquete', 'loxística', 'lliurament', 'enviament', 'paquet'] },
|
||||
{ key: 'calendars', description: 'calendars, dates, upcoming dates in my calendars, deadlines, reminders', keywords: ['calendar', 'calendario', 'date', 'fecha', 'deadline', 'recordatorio', 'reminder', 'calendari', 'termini', 'prazo', 'recordatori'] },
|
||||
{ key: 'votes', description: 'votations, polls, open votes, voting, ballots, what can I vote on', keywords: ['vote', 'votación', 'votar', 'poll', 'encuesta', 'ballot', 'referendum', 'votació', 'enquesta', 'enquisa', 'enquestes'] },
|
||||
{ key: 'wiki', description: 'wiki pages, knowledge base, documentation written by inhabitants', keywords: ['wiki'] },
|
||||
{ key: 'chats', description: 'my chats, group chats, conversations, chat rooms', keywords: ['chat', 'conversación', 'sala', 'xat', 'conversa'] },
|
||||
{ key: 'mailing', description: 'mailing lists, subscriptions, newsletters, discussion lists', keywords: ['mailing', 'lista de correo', 'newsletter', 'subscri', 'llista de correu'] },
|
||||
{ key: 'podcasts', description: 'podcasts, audio shows, episodes, radio', keywords: ['podcast', 'episode', 'episodio', 'radio', 'episodi'] },
|
||||
{ key: 'media', description: 'audios, videos, images, documents, files, media library, how many files', keywords: ['audio', 'video', 'vídeo', 'image', 'imagen', 'photo', 'foto', 'document', 'documento', 'file', 'archivo', 'media', 'gallery', 'galería', 'torrent', 'àudio', 'imatge', 'imaxe', 'fitxer', 'arxiu', 'ficheiro', 'arquivo', 'galeria'] },
|
||||
{ key: 'blogs', description: 'blogs, blog entries, articles, latest posts written by inhabitants', keywords: ['blog', 'entrada', 'article', 'artículo', 'artigo'] },
|
||||
{ key: 'reports', description: 'reports, complaints, flagged content, incidents', keywords: ['report', 'reporte', 'denuncia', 'complaint', 'queja', 'flag', 'incident', 'denúncia', 'queixa', 'informe'] },
|
||||
{ key: 'games', description: 'games, hall of fame, scores, best players, play', keywords: ['game', 'juego', 'play', 'jugar', 'score', 'puntuación', 'hall of fame', 'ranking', ' joc', 'xogo', 'xogar', 'puntuació'] },
|
||||
{ key: 'tags', description: 'tags, hashtags, topics, most used tags, what is the network talking about', keywords: ['tag', 'etiqueta', 'hashtag', 'topic', 'tema', 'trending', 'tendencia', 'tendència', 'etiquetes'] },
|
||||
{ key: 'favorites', description: 'my favorites, things I starred, saved items, bookmarks', keywords: ['favorite', 'favorito', 'starred', 'saved', 'guardado', 'bookmark', 'marcador', 'preferit', 'favorit', 'desat', 'gardado'] },
|
||||
{ key: 'modules', description: 'which modules are enabled, what features does this node have, oasis modules', keywords: ['module', 'módulo', 'feature', 'funcionalidad', 'enabled', 'activado', 'disabled', 'desactivado', 'plugin', 'mòdul', 'funcionalitat', 'activat', 'desactivat'] },
|
||||
{ key: 'help', description: 'what can you do, help, how can you help me, what do you know, commands', keywords: ['what can you', 'qué puedes', 'help', 'ayuda', 'how can you', 'cómo puedes', 'what do you know', 'qué sabes', 'capabilities', 'què pots', 'que podes', 'ajuda', 'axuda', 'com pots', 'como podes', 'què saps'] }
|
||||
];
|
||||
|
||||
const KEYWORD_MIN = 1;
|
||||
const EMBED_MIN = 0.55;
|
||||
const EMBED_MARGIN = 0.05;
|
||||
|
||||
const norm = (s) => String(s || '').toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '');
|
||||
|
||||
const detectByKeywords = (question) => {
|
||||
const q = ' ' + norm(question) + ' ';
|
||||
let best = null;
|
||||
for (const it of INTENTS) {
|
||||
const hits = it.keywords.filter(k => q.includes(norm(k))).length;
|
||||
if (hits >= KEYWORD_MIN && (!best || hits > best.hits)) best = { key: it.key, hits };
|
||||
}
|
||||
return best ? best.key : null;
|
||||
};
|
||||
|
||||
let intentVectors = null;
|
||||
const detect = async (question, { embed, cosine } = {}) => {
|
||||
const byKeywords = detectByKeywords(question);
|
||||
if (byKeywords) return byKeywords;
|
||||
if (typeof embed !== 'function' || typeof cosine !== 'function') return null;
|
||||
const qv = await embed(question).catch(() => null);
|
||||
if (!qv) return null;
|
||||
if (!intentVectors) {
|
||||
const out = [];
|
||||
for (const it of INTENTS) { const v = await embed(it.description).catch(() => null); if (v) out.push({ key: it.key, v }); }
|
||||
intentVectors = out;
|
||||
}
|
||||
const scored = intentVectors.map(it => ({ key: it.key, s: cosine(qv, it.v) })).sort((a, b) => b.s - a.s);
|
||||
const best = scored[0];
|
||||
const second = scored[1];
|
||||
if (!best || best.s < EMBED_MIN) return null;
|
||||
if (second && best.s - second.s < EMBED_MARGIN) return null;
|
||||
return best.key;
|
||||
};
|
||||
|
||||
const fmtEco = (n) => `${Number(n || 0).toFixed(6)} ECO`;
|
||||
const fmtDate = (d) => { const t = new Date(d || 0); return Number.isFinite(t.getTime()) && t.getTime() > 0 ? t.toISOString().replace('T', ' ').slice(0, 16) : ''; };
|
||||
const titleOf = (c) => String(c.title || c.name || c.question || c.concept || c.subject || '').trim();
|
||||
const dateOf = (c) => c.date || c.startTime || c.startDate || c.deadline || c.endTime || c.createdAt || null;
|
||||
const line = (text, href) => ({ text, href: href || null });
|
||||
|
||||
const RUNNERS = {
|
||||
async funds({ models, me }) {
|
||||
const out = [];
|
||||
const data = await models.banking.listBanking('overview', me).catch(() => null);
|
||||
const summary = (data && data.summary) || {};
|
||||
const addr = await models.banking.getUserAddress(me).catch(() => null);
|
||||
if (summary.hasValidWallet) out.push(line(`Wallet balance: ${fmtEco(summary.userBalance)}`, '/wallet'));
|
||||
else out.push(line('Wallet: not connected (Settings › Wallet)', '/settings#wallet'));
|
||||
out.push(line(addr ? `ECOin address: ${addr}` : 'ECOin address: none published', '/banking?filter=addresses'));
|
||||
if (data && data.exchange && data.exchange.isSynced) out.push(line(`ECO value (1h): ${Number(data.exchange.ecoValue || 0).toFixed(4)} ECO`, '/banking?filter=exchange'));
|
||||
if (Number.isFinite(Number(summary.industryBalance)) && Number(summary.industryBalance) !== 0) out.push(line(`Industry balance: ${fmtEco(summary.industryBalance)}`, '/industry'));
|
||||
return out;
|
||||
},
|
||||
async exchange({ models, me }) {
|
||||
const data = await models.banking.listBanking('exchange', me).catch(() => null);
|
||||
const ex = (data && data.exchange) || {};
|
||||
if (!ex.isSynced) return [line('ECO value: unknown right now, the wallet is not synced with the ECOin network', '/banking?filter=exchange')];
|
||||
const hours = Number(ex.ecoTimeMs || 0) / 3600000;
|
||||
const out = [
|
||||
line(`Value of 1 ECO right now: ${Number(ex.ecoValue || 0).toFixed(4)} ECO per hour of UBI (1 ECO ≈ ${hours > 0 ? hours.toFixed(2) + ' hours' : 'n/a'})`, '/banking?filter=exchange'),
|
||||
line(`ECOin total supply: ${fmtEco(ex.totalSupply)} · current supply: ${fmtEco(ex.currentSupply)}`, '/banking?filter=exchange'),
|
||||
line(`Inflation: ${Number(ex.inflationFactor || 0).toFixed(2)}% annual · ${Number(ex.inflationMonthly || 0).toFixed(2)}% monthly`, '/banking?filter=exchange')
|
||||
];
|
||||
if (ex.pubsSupply != null) out.push(line(`PUBs pool supply: ${fmtEco(ex.pubsSupply)}${ex.holdingSupply != null ? ` · holding supply: ${fmtEco(ex.holdingSupply)}` : ''}`, '/banking?filter=exchange'));
|
||||
return out;
|
||||
},
|
||||
async ubi({ models, me }) {
|
||||
const out = [];
|
||||
const data = await models.banking.listBanking('ubi', me).catch(() => null);
|
||||
const summary = (data && data.summary) || {};
|
||||
const bank = await models.banking.getBankingData(me).catch(() => ({}));
|
||||
if (summary.epochId) out.push(line(`Current epoch: ${summary.epochId}`, '/banking?filter=ubi'));
|
||||
if (summary.alreadyClaimed) out.push(line('This month: already claimed', '/banking?filter=ubi'));
|
||||
else if (summary.alreadyRefused) out.push(line('This month: refused', '/banking?filter=ubi'));
|
||||
else if (data && data.pendingUBI) out.push(line(`This month: ${fmtEco(data.pendingUBI.amount)} available to claim`, '/banking?filter=ubi'));
|
||||
else out.push(line(`This month: nothing to claim yet (${summary.ubiAvailability === 'NO_FUNDS' ? 'PUB without funds' : 'no allocation'})`, '/banking?filter=ubi'));
|
||||
if (bank.estimatedUBI != null) out.push(line(`Estimated UBI: ${fmtEco(bank.estimatedUBI)}`, '/banking?filter=ubi'));
|
||||
out.push(line(`Total received: ${fmtEco(bank.totalClaimed)}${bank.lastClaimedDate ? ` (last: ${fmtDate(bank.lastClaimedDate)})` : ''}`, '/banking?filter=ubi'));
|
||||
const pubs = Array.isArray(data && data.ubiPubs) ? data.ubiPubs : [];
|
||||
if (pubs.length) out.push(line(`PUBs announcing UBI: ${pubs.length}`, '/banking?filter=ubi'));
|
||||
return out;
|
||||
},
|
||||
async karma({ models, me }) {
|
||||
const bank = await models.banking.getBankingData(me).catch(() => ({}));
|
||||
return [
|
||||
line(`Karma score: ${Number(bank.karmaScore || 0).toFixed(2)}`, '/banking?filter=overview'),
|
||||
line(`ECOin tax: ${fmtEco(bank.userEcoinTax)} · ARCH tax: ${fmtEco(bank.userArchTax)} · total: ${fmtEco(bank.userTotalTax)}`, '/banking?filter=taxes'),
|
||||
line(`Estimated UBI after taxes: ${fmtEco(bank.estimatedUBI)}`, '/banking?filter=ubi')
|
||||
];
|
||||
},
|
||||
async agenda({ models, hrefFor }) {
|
||||
const items = await models.agenda.listAgenda('all').catch(() => []);
|
||||
const now = Date.now();
|
||||
const soon = (Array.isArray(items) ? items : [])
|
||||
.map(it => ({ it, t: new Date(dateOf(it) || 0).getTime() }))
|
||||
.filter(x => Number.isFinite(x.t) && x.t >= now - 3600000)
|
||||
.sort((a, b) => a.t - b.t)
|
||||
.slice(0, 8);
|
||||
if (!soon.length) return [line('Agenda: nothing upcoming', '/agenda')];
|
||||
return soon.map(({ it, t }) => line(`${fmtDate(t)} · ${titleOf(it) || it.type || 'item'}${it.type ? ` (${it.type})` : ''}`, hrefFor(it.type, it.id || it.tipId)));
|
||||
},
|
||||
async events({ models, hrefFor }) {
|
||||
const events = await models.events.listAll(null, 'all').catch(() => []);
|
||||
const now = Date.now();
|
||||
const upcoming = (Array.isArray(events) ? events : [])
|
||||
.map(e => ({ e, t: new Date(e.date || 0).getTime() }))
|
||||
.filter(x => Number.isFinite(x.t) && x.t >= now - 3600000 && String(x.e.status || '').toUpperCase() !== 'CLOSED')
|
||||
.sort((a, b) => a.t - b.t).slice(0, 8);
|
||||
if (!upcoming.length) return [line('Events: none upcoming', '/events')];
|
||||
return upcoming.map(({ e, t }) => line(`${fmtDate(t)} · ${titleOf(e)}${e.location ? ` · ${e.location}` : ''}${Number(e.price) > 0 ? ` · ${fmtEco(e.price)}` : ''}`, hrefFor('event', e.id)));
|
||||
},
|
||||
async tasks({ models, me, hrefFor }) {
|
||||
const tasks = await models.tasks.listAll().catch(() => []);
|
||||
const mine = (Array.isArray(tasks) ? tasks : []).filter(t => String(t.status || '').toUpperCase() === 'OPEN' && (t.author === me || (Array.isArray(t.assignees) && t.assignees.includes(me))));
|
||||
if (!mine.length) return [line('Tasks: nothing open assigned to you', '/tasks')];
|
||||
return mine.slice(0, 10).map(t => line(`${titleOf(t)}${t.endTime ? ` · due ${fmtDate(t.endTime)}` : ''}`, hrefFor('task', t.id)));
|
||||
},
|
||||
async transfers({ models, me, hrefFor }) {
|
||||
const all = await models.transfers.listAll('all').catch(() => []);
|
||||
const pending = (Array.isArray(all) ? all : []).filter(t => String(t.status || '').toUpperCase() === 'UNCONFIRMED' && (t.to === me || t.from === me) && !(Array.isArray(t.confirmedBy) && t.confirmedBy.includes(me)));
|
||||
if (!pending.length) return [line('Transfers: nothing waiting for your confirmation', '/transfers?filter=pending')];
|
||||
return pending.slice(0, 10).map(t => line(`${fmtEco(t.amount)} · ${t.concept || t.id} · ${t.from === me ? 'to' : 'from'} ${t.from === me ? t.to : t.from}`, hrefFor('transfer', t.id)));
|
||||
},
|
||||
async relationships({ models, me }) {
|
||||
const users = await models.inhabitants.listInhabitants({ filter: 'all', includeInactive: true }).catch(() => []);
|
||||
const followers = [], following = [], mutuals = [];
|
||||
for (const u of (Array.isArray(users) ? users : []).slice(0, 400)) {
|
||||
if (!u || !u.id || u.id === me) continue;
|
||||
const rel = await models.friend.getRelationship(u.id).catch(() => null);
|
||||
if (!rel) continue;
|
||||
const name = u.name || u.id.slice(0, 9);
|
||||
if (rel.following && rel.followsMe) mutuals.push(name);
|
||||
else if (rel.following) following.push(name);
|
||||
else if (rel.followsMe) followers.push(name);
|
||||
}
|
||||
const list = (arr) => arr.slice(0, 12).join(', ') + (arr.length > 12 ? '…' : '');
|
||||
return [
|
||||
line(`Mutual contacts: ${mutuals.length}${mutuals.length ? ` · ${list(mutuals)}` : ''}`, '/inhabitants?filter=mutuals'),
|
||||
line(`You follow: ${following.length + mutuals.length}${following.length ? ` · also ${list(following)}` : ''}`, '/inhabitants?filter=following'),
|
||||
line(`Follow you: ${followers.length + mutuals.length}${followers.length ? ` · also ${list(followers)}` : ''}`, '/inhabitants?filter=followers')
|
||||
];
|
||||
},
|
||||
async inbox({ sharedState }) {
|
||||
return [
|
||||
line(`Unread private messages: ${Number(sharedState.getInboxPmCount ? sharedState.getInboxPmCount() : 0) || 0}`, '/inbox?filter=pms'),
|
||||
line(`Unread bot notifications: ${Number(sharedState.getInboxNotifCount ? sharedState.getInboxNotifCount() : 0) || 0}`, '/inbox?filter=notifications'),
|
||||
line(`Unread mentions: ${Number(sharedState.getMentionsCount ? sharedState.getMentionsCount() : 0) || 0}`, '/mentions')
|
||||
];
|
||||
},
|
||||
async government({ models, me, nameOf }) {
|
||||
const out = [];
|
||||
const card = await models.parliament.getLatestGovernmentCard().catch(() => null);
|
||||
if (card) {
|
||||
const leader = card.leaderId ? await nameOf(card.leaderId) : '';
|
||||
out.push(line(`Government: ${String(card.method || '').toUpperCase()}${leader ? ` · leader ${leader}` : ''}${card.end ? ` · cycle ends ${fmtDate(card.end)}` : ''}`, '/parliament'));
|
||||
} else out.push(line('Government: no cycle published yet', '/parliament'));
|
||||
if (models.larp && typeof models.larp.getUserHouse === 'function') {
|
||||
const house = await models.larp.getUserHouse(me).catch(() => null);
|
||||
if (house) out.push(line(`Your LARP house: ${house}`, '/larp'));
|
||||
}
|
||||
return out;
|
||||
},
|
||||
async emergencies({ models, me, hrefFor }) {
|
||||
const all = await models.emergencies.listAll({ filter: 'all', viewerId: me }).catch(() => []);
|
||||
const active = (Array.isArray(all) ? all : []).filter(e => String(e.status || '').toUpperCase() !== 'RESOLVED').slice(0, 8);
|
||||
if (!active.length) return [line('Emergencies: none active', '/emergencies')];
|
||||
return active.map(e => line(`${titleOf(e)}${e.status ? ` · ${e.status}` : ''}`, hrefFor('emergency', e.id)));
|
||||
},
|
||||
async jobs({ models, me, hrefFor }) {
|
||||
const jobs = await models.jobs.listJobs('ALL', me).catch(() => []);
|
||||
const open = (Array.isArray(jobs) ? jobs : []).filter(j => String(j.status || 'OPEN').toUpperCase() !== 'CLOSED').slice(0, 8);
|
||||
if (!open.length) return [line('Jobs: no open offers', '/jobs')];
|
||||
return open.map(j => line(`${titleOf(j)}${Number(j.salary) > 0 ? ` · ${fmtEco(j.salary)}` : ''}`, hrefFor('job', j.id)));
|
||||
},
|
||||
async market({ models, hrefFor }) {
|
||||
const items = await models.market.listAllItems('all').catch(() => []);
|
||||
const forSale = (Array.isArray(items) ? items : []).filter(i => String(i.status || '').toUpperCase() === 'FOR SALE' || String(i.status || '').toUpperCase() === 'OPEN' || !i.status).slice(0, 8);
|
||||
if (!forSale.length) return [line('Market: nothing for sale right now', '/market')];
|
||||
return forSale.map(i => line(`${titleOf(i)}${i.price != null ? ` · ${fmtEco(i.price)}` : ''}`, hrefFor('market', i.id)));
|
||||
},
|
||||
async tribes({ models, me, hrefFor }) {
|
||||
const all = await models.tribes.listAll().catch(() => []);
|
||||
const mine = (Array.isArray(all) ? all : []).filter(t => Array.isArray(t.members) && t.members.includes(me));
|
||||
if (!mine.length) return [line('Tribes: you are not in any tribe', '/tribes')];
|
||||
return mine.slice(0, 10).map(t => line(`${titleOf(t)} · ${t.members.length} members`, hrefFor('tribe', t.id)));
|
||||
},
|
||||
async network({ sharedState }) {
|
||||
const ts = sharedState.getLastSyncTs ? sharedState.getLastSyncTs() : null;
|
||||
return [
|
||||
line(`Inhabitants: ${Number(sharedState.getInhabitantCount ? sharedState.getInhabitantCount() : 0) || 0}`, '/inhabitants'),
|
||||
line(`Tribes: ${Number(sharedState.getTribesCount ? sharedState.getTribesCount() : 0) || 0}`, '/tribes'),
|
||||
line(`Peers online: ${Number(sharedState.getOnlinePeerCount ? sharedState.getOnlinePeerCount() : 0) || 0}`, '/peers'),
|
||||
line(`Last sync: ${ts ? fmtDate(ts) : 'never'}`, '/peers'),
|
||||
line(`ECO value (1h): ${sharedState.getEcoValue && sharedState.getEcoValue() ? sharedState.getEcoValue() + ' ECO' : 'unknown (wallet not synced)'}`, '/banking?filter=exchange')
|
||||
];
|
||||
},
|
||||
async me({ me, nameOf, version }) {
|
||||
const name = await nameOf(me);
|
||||
return [
|
||||
line(`You are ${name} (${me})`, '/profile'),
|
||||
line(`Oasis version: ${version || 'unknown'}`, '/settings')
|
||||
];
|
||||
}
|
||||
};
|
||||
|
||||
const listOf = (x) => Array.isArray(x) ? x : (x && Array.isArray(x.items) ? x.items : []);
|
||||
const isOpen = (x) => { const s = String(x && x.status || 'OPEN').toUpperCase(); return !['CLOSED', 'RESOLVED', 'EXPIRED', 'DISSOLVED', 'CANCELLED', 'DELETED'].includes(s); };
|
||||
const few = (arr, n = 8) => arr.slice(0, n);
|
||||
|
||||
Object.assign(RUNNERS, {
|
||||
async shops({ models, hrefFor }) {
|
||||
const items = listOf(await models.shops.listAllProducts({ filter: 'all' }).catch(() => []));
|
||||
const open = few(items.filter(isOpen));
|
||||
if (!open.length) return [line('Shops: no products on sale right now', '/shops')];
|
||||
return open.map(p => line(`${titleOf(p)}${p.price != null ? ` · ${fmtEco(p.price)}` : ''}${Number(p.stock) > 0 ? ` · stock ${p.stock}` : ''}`, hrefFor('shopProduct', p.id || p.key)));
|
||||
},
|
||||
async school({ models, me, hrefFor }) {
|
||||
const courses = listOf(await models.school.listCourses('ALL', me, {}).catch(() => []));
|
||||
const mine = courses.filter(c => c.author === me || (Array.isArray(c.students) && c.students.includes(me)));
|
||||
const open = few(courses.filter(isOpen));
|
||||
const out = [line(`School: ${courses.length} courses, ${mine.length} of them yours (teaching or enrolled)`, '/school')];
|
||||
return out.concat(open.map(c => line(`${titleOf(c)}${Number(c.price) > 0 ? ` · ${fmtEco(c.price)}` : ' · free'}${Array.isArray(c.students) ? ` · ${c.students.length} students` : ''}`, hrefFor('schoolCourse', c.id))));
|
||||
},
|
||||
async projects({ models, hrefFor }) {
|
||||
const projects = listOf(await models.projects.listProjects('all', {}).catch(() => []));
|
||||
const open = few(projects.filter(isOpen));
|
||||
if (!open.length) return [line('Projects: none looking for funding right now', '/projects')];
|
||||
return open.map(p => line(`${titleOf(p)}${p.goal != null ? ` · goal ${fmtEco(p.goal)}` : ''}${p.pledged != null ? ` · pledged ${fmtEco(p.pledged)}` : ''}${p.deadline ? ` · until ${fmtDate(p.deadline)}` : ''}`, hrefFor('project', p.id)));
|
||||
},
|
||||
async housing({ models, me, hrefFor }) {
|
||||
const items = listOf(await models.housing.listHousing('ALL', me, {}).catch(() => []));
|
||||
const open = few(items.filter(isOpen));
|
||||
if (!open.length) return [line('Housing: no places offered right now', '/housing')];
|
||||
return open.map(h => line(`${titleOf(h)}${h.price != null ? ` · ${fmtEco(h.price)}` : ''}${h.location ? ` · ${h.location}` : ''}`, hrefFor('housing', h.id)));
|
||||
},
|
||||
async industry({ models, me, hrefFor }) {
|
||||
const facilities = listOf(await models.industry.listFacilities('ALL').catch(() => []));
|
||||
const mine = facilities.filter(f => Array.isArray(f.members) && f.members.includes(me));
|
||||
const out = [line(`Industry: ${facilities.length} facilities, you belong to ${mine.length}`, '/industry')];
|
||||
return out.concat(few(facilities.filter(isOpen)).map(f => line(`${titleOf(f)}${Array.isArray(f.members) ? ` · ${f.members.length} members` : ''}`, hrefFor('industry', f.id))));
|
||||
},
|
||||
async campaigns({ models, me, hrefFor }) {
|
||||
const items = listOf(await models.campaigns.listAll({ filter: 'all', viewerId: me }).catch(() => []));
|
||||
const open = few(items.filter(isOpen));
|
||||
if (!open.length) return [line('Campaigns: none open right now', '/campaigns')];
|
||||
return open.map(c => line(`${titleOf(c)}${Array.isArray(c.signers) ? ` · ${c.signers.length} signatures` : ''}${c.goal ? ` of ${c.goal}` : ''}`, hrefFor('campaign', c.id)));
|
||||
},
|
||||
async logistics({ models, hrefFor }) {
|
||||
const routes = listOf(await models.logistics.listAll({ filter: 'all' }).catch(() => []));
|
||||
const open = few(routes.filter(isOpen));
|
||||
if (!open.length) return [line('Logistics: no open routes right now', '/logistics')];
|
||||
return open.map(r => line(`${titleOf(r)}${r.price != null && Number(r.price) > 0 ? ` · ${Number(r.price)} ${r.priceType === 'TIME' ? 'h' : 'ECO'}` : ''}${r.status ? ` · ${r.status}` : ''}`, hrefFor('logisticsRoute', r.id)));
|
||||
},
|
||||
async calendars({ models, me, hrefFor }) {
|
||||
const cals = listOf(await models.calendars.listAll({ filter: 'all', viewerId: me }).catch(() => []));
|
||||
if (!cals.length) return [line('Calendars: you are not in any calendar', '/calendars')];
|
||||
const now = Date.now();
|
||||
const soon = cals.map(c => ({ c, t: new Date(dateOf(c) || 0).getTime() })).filter(x => Number.isFinite(x.t) && x.t >= now - 3600000).sort((a, b) => a.t - b.t);
|
||||
const out = [line(`Calendars: ${cals.length}`, '/calendars')];
|
||||
return out.concat(few(soon).map(({ c, t }) => line(`${fmtDate(t)} · ${titleOf(c)}`, hrefFor('calendar', c.id))));
|
||||
},
|
||||
async votes({ models, hrefFor }) {
|
||||
const votes = listOf(await models.votes.listAll('all').catch(() => []));
|
||||
const polls = listOf(await models.polls.listAll('ALL', {}).catch(() => []));
|
||||
const openV = few(votes.filter(isOpen), 5);
|
||||
const openP = few(polls.filter(isOpen), 5);
|
||||
if (!openV.length && !openP.length) return [line('Votes: nothing open to vote on right now', '/votes')];
|
||||
return openV.map(v => line(`Votation: ${titleOf(v)}${v.deadline ? ` · until ${fmtDate(v.deadline)}` : ''}`, hrefFor('vote', v.id)))
|
||||
.concat(openP.map(p => line(`Poll: ${titleOf(p)}`, hrefFor('poll', p.id))));
|
||||
},
|
||||
async wiki({ models, me, hrefFor }) {
|
||||
const pages = listOf(await models.wiki.listPages({ viewerId: me }).catch(() => []));
|
||||
if (!pages.length) return [line('Wiki: no pages yet', '/wiki')];
|
||||
const out = [line(`Wiki: ${pages.length} pages`, '/wiki')];
|
||||
return out.concat(few(pages, 6).map(p => line(titleOf(p), hrefFor('wikiPage', p.id || p.slug))));
|
||||
},
|
||||
async chats({ models, me, hrefFor }) {
|
||||
const chats = listOf(await models.chats.listAll({ filter: 'all', viewerId: me }).catch(() => []));
|
||||
const mine = chats.filter(c => c.author === me || (Array.isArray(c.members) && c.members.includes(me)) || (Array.isArray(c.participants) && c.participants.includes(me)));
|
||||
if (!mine.length) return [line('Chats: you are not in any chat', '/chats')];
|
||||
return few(mine).map(c => line(`${titleOf(c)}${Array.isArray(c.members) ? ` · ${c.members.length} members` : ''}`, hrefFor('chat', c.key || c.id)));
|
||||
},
|
||||
async mailing({ models, hrefFor }) {
|
||||
const lists = listOf(await models.mailing.listAll({ filter: 'all' }).catch(() => []));
|
||||
const active = few(lists.filter(l => String(l.status || 'ACTIVE').toUpperCase() === 'ACTIVE'));
|
||||
if (!active.length) return [line('Mailing lists: none active', '/mailing')];
|
||||
return active.map(l => line(`${titleOf(l)}${Array.isArray(l.subscribers) ? ` · ${l.subscribers.length} subscribers` : ''}`, hrefFor('mailingList', l.id)));
|
||||
},
|
||||
async podcasts({ models, me, hrefFor }) {
|
||||
const channels = listOf(await models.podcasts.listAll({ filter: 'all', viewerId: me }).catch(() => []));
|
||||
if (!channels.length) return [line('Podcasts: none yet', '/podcasts')];
|
||||
return few(channels).map(c => line(`${titleOf(c)}${Array.isArray(c.episodes) ? ` · ${c.episodes.length} episodes` : ''}`, hrefFor('podcast', c.id)));
|
||||
},
|
||||
async media({ models }) {
|
||||
const count = async (m) => m && typeof m.listAll === 'function' ? listOf(await m.listAll('all').catch(() => [])).length : 0;
|
||||
const [audios, videos, images, documents, torrents, files] = await Promise.all([count(models.audios), count(models.videos), count(models.images), count(models.documents), count(models.torrents), count(models.files)]);
|
||||
return [
|
||||
line(`Audios: ${audios}`, '/audios'), line(`Videos: ${videos}`, '/videos'), line(`Images: ${images}`, '/images'),
|
||||
line(`Documents: ${documents}`, '/documents'), line(`Files: ${files}`, '/files'), line(`Torrents: ${torrents}`, '/torrents')
|
||||
];
|
||||
},
|
||||
async blogs({ models }) {
|
||||
const blogs = listOf(await models.blog.listAll('all').catch(() => []));
|
||||
if (!blogs.length) return [line('Blogs: no entries yet', '/blogs')];
|
||||
const latest = blogs.slice().sort((a, b) => new Date(dateOf(b) || 0) - new Date(dateOf(a) || 0));
|
||||
return [line(`Blogs: ${blogs.length} entries`, '/blogs')].concat(few(latest, 6).map(b => line(`${titleOf(b) || 'Untitled'}${dateOf(b) ? ` · ${fmtDate(dateOf(b))}` : ''}`, b.key || b.id ? `/blogs/${encodeURIComponent(b.key || b.id)}` : '/blogs')));
|
||||
},
|
||||
async reports({ models, hrefFor }) {
|
||||
const reports = listOf(await models.reports.listAll().catch(() => []));
|
||||
const open = few(reports.filter(isOpen));
|
||||
if (!open.length) return [line('Reports: none open', '/reports')];
|
||||
return open.map(r => line(`${titleOf(r)}${r.status ? ` · ${r.status}` : ''}`, hrefFor('report', r.id)));
|
||||
},
|
||||
async games({ models, nameOf }) {
|
||||
const hall = listOf(await models.games.getHallOfFame().catch(() => []));
|
||||
if (!hall.length) return [line('Games: no scores yet', '/games')];
|
||||
const out = [];
|
||||
for (const h of few(hall, 6)) out.push(line(`${h.game || 'game'} · ${await nameOf(h.author)} · ${h.score != null ? h.score : ''}`, '/games'));
|
||||
return out;
|
||||
},
|
||||
async tags({ models }) {
|
||||
const tags = listOf(await models.tags.listTags('all', '').catch(() => []));
|
||||
if (!tags.length) return [line('Tags: none yet', '/tags')];
|
||||
const top = tags.slice().sort((a, b) => (Number(b.count) || 0) - (Number(a.count) || 0));
|
||||
return [line(`Tags in use: ${tags.length}`, '/tags')].concat(few(top, 10).map(t => line(`#${t.name || t.tag || t.id || t}${t.count != null ? ` · ${t.count}` : ''}`, `/tags/${encodeURIComponent(String(t.name || t.tag || t.id || t))}`)));
|
||||
},
|
||||
async favorites({ models }) {
|
||||
const res = await models.favorites.listAll({}).catch(() => null);
|
||||
const counts = res && res.counts && typeof res.counts === 'object' ? res.counts : {};
|
||||
const keys = Object.keys(counts).filter(k => Number(counts[k]) > 0);
|
||||
if (!keys.length) return [line('Favorites: nothing starred yet', '/favorites')];
|
||||
return keys.map(k => line(`${k}: ${counts[k]}`, `/favorites?filter=${encodeURIComponent(k)}`));
|
||||
},
|
||||
async modules({ config }) {
|
||||
const mods = (config && config.modules) || {};
|
||||
const on = Object.keys(mods).filter(k => k.endsWith('Mod') && mods[k] === 'on').map(k => k.slice(0, -3));
|
||||
const off = Object.keys(mods).filter(k => k.endsWith('Mod') && mods[k] !== 'on').map(k => k.slice(0, -3));
|
||||
return [line(`Enabled modules (${on.length}): ${on.join(', ')}`, '/modules'), line(`Disabled modules (${off.length}): ${off.join(', ') || 'none'}`, '/modules')];
|
||||
},
|
||||
async help() {
|
||||
return [
|
||||
line('I answer from the live data of this node: your funds and ECO value, your UBI and karma, your agenda, events, tasks, transfers, who follows you, your inbox and mentions, the government, emergencies, jobs, market, shops, school, projects, housing, industry, campaigns, logistics, calendars, votes, wiki, chats, mailing lists, podcasts, media, blogs, reports, games, tags, favorites, the modules and the network status.', '/ai'),
|
||||
line('Anything else I answer with the model, helped by the answers the network approved. Rate my answers with the stars so they are shared and I learn.', '/ai')
|
||||
];
|
||||
}
|
||||
});
|
||||
|
||||
const run = async (key, deps) => {
|
||||
const runner = RUNNERS[key];
|
||||
if (!runner) return null;
|
||||
try { return await runner(deps); } catch (_) { return null; }
|
||||
};
|
||||
|
||||
module.exports = { INTENTS, detect, detectByKeywords, run, RUNNERS };
|
||||
2677
src/AI/package-lock.json
generated
|
|
@ -1,18 +0,0 @@
|
|||
{
|
||||
"name": "@krakenslab/oasis-ai",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "AI stack of Oasis (assistant and embeddings); installed only when AI features are enabled",
|
||||
"dependencies": {
|
||||
"@xenova/transformers": "^2.17.2",
|
||||
"onnxruntime-node": "1.19.2"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"node-llama-cpp": "^3.10.0"
|
||||
},
|
||||
"overrides": {
|
||||
"onnxruntime-node": "1.19.2",
|
||||
"onnxruntime-common": "1.19.2",
|
||||
"onnxruntime-web": "1.19.2"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,275 +0,0 @@
|
|||
const path = require('path')
|
||||
const fs = require('fs')
|
||||
|
||||
const ROUTES = [
|
||||
{ path: '/blogs', mod: 'blogsMod', description: 'blogs, blog posts, articles, written entries, long posts, public writing' },
|
||||
{ path: '/polls', mod: 'pollsMod', description: 'polls, survey, ask a question with options, multiple choice, anonymous poll, count answers' },
|
||||
{ path: '/polls?filter=CREATE', mod: 'pollsMod', description: 'create a poll, new survey, ask the network with options' },
|
||||
{ path: '/blogs?filter=CREATE', mod: 'blogsMod', description: 'write a blog, publish a blog, compose an article, new blog entry' },
|
||||
{ path: '/blogs?filter=TOP', mod: 'blogsMod', description: 'best blogs, most commented blogs, most valued blog entries' },
|
||||
{ path: '/feed', mod: 'feedMod', description: 'feed, microblog, opinions, share thoughts, vote on posts, refeeds' },
|
||||
{ path: '/forum', mod: 'forumMod', description: 'forum, discussions, threads, debates, conversation by category' },
|
||||
{ path: '/inhabitants', mod: null, description: 'inhabitants, users, people, profiles, contacts, follow, block' },
|
||||
{ path: '/inhabitants?filter=SUGGESTED', mod: null, description: 'suggested inhabitants, recommendations, who to follow, similar people, people you might know, friend suggestions' },
|
||||
{ path: '/data', mod: null, description: 'matches, match skills, cross-data, big data of the network, affinities, cohesion coefficient, who shares my expertise, skill overlap, algorithmic suggestions' },
|
||||
{ path: '/data?filter=INHABITANTS', mod: null, description: 'people with the same skills, professional matches, find collaborators' },
|
||||
{ path: '/data?filter=JOBS', mod: null, description: 'jobs that match my skills, work suited to my profile' },
|
||||
{ path: '/inhabitants?filter=CVs', mod: null, description: 'curriculums, CVs, resumes, professional profiles, people with experience, find expertise' },
|
||||
{ path: '/inhabitants?filter=TOP%20KARMA', mod: null, description: 'top karma, most active inhabitants, highest reputation, leaderboard' },
|
||||
{ path: '/inhabitants?filter=TOP%20ECO', mod: null, description: 'top eco, most ecological, least carbon footprint, sustainable users, efficient inhabitants' },
|
||||
{ path: '/inhabitants?filter=TOP%20ACTIVITY', mod: null, description: 'top activity, most recently active inhabitants, fresh users, recently online' },
|
||||
{ path: '/inhabitants?filter=contacts', mod: null, description: 'my contacts, who I follow, my network, friends list, mutuals' },
|
||||
{ path: '/inhabitants?filter=GALLERY', mod: null, description: 'gallery of inhabitants, all avatars, visual list, photos' },
|
||||
{ path: '/tribes', mod: 'tribesMod', description: 'tribes, groups, communities, private rooms, sub-tribes, governance, public tribes, browse all visible tribes' },
|
||||
{ path: '/tribes?filter=mine', mod: 'tribesMod', description: 'my tribes, tribes I created, tribes I authored, my groups' },
|
||||
{ path: '/tribes?filter=membership', mod: 'tribesMod', description: 'tribes I am a member of, my memberships, joined tribes, where I belong' },
|
||||
{ path: '/larp', mod: 'larpMod', description: 'larp, role playing, nine houses, academia, solaris, arrakis, terraverde, unsystem, dogma, helix, quark, hermandad, governance cycle, ruling house, will test' },
|
||||
{ path: '/larp?filter=houses', mod: 'larpMod', description: 'list of houses, all houses, browse houses, every house, choose a house, house directory, house grid' },
|
||||
{ path: '/larp?filter=rules', mod: 'larpMod', description: 'larp faq, rules, governance wall, starting house, will test, invitation code, larp emblem' },
|
||||
{ path: '/larp/academia', mod: 'larpMod', description: 'ACADEMIA house, aca, education, teachers, coordinators, newcomers, talent discovery, selection process, nosce te ipsum, take will test, leave house, return to academia' },
|
||||
{ path: '/larp/solaris', mod: 'larpMod', description: 'SolarIS house, sol, governance, law, diplomacy, politics, rulers, lawyers, diplomats, communication, mediation' },
|
||||
{ path: '/larp/arrakis', mod: 'larpMod', description: 'ARRakis house, arr, engineers, scientists, technicians, technology, invention, problem solving, building, prototypes, repairs, automation' },
|
||||
{ path: '/larp/terraverde', mod: 'larpMod', description: 'TERRA.VErDE house, ter, farmers, ecologists, doctors, nutritionists, healthcare, food, nature, climate, biosphere, regeneration' },
|
||||
{ path: '/larp/unsystem', mod: 'larpMod', description: 'UNSYSTem house, uns, chaos agents, trolls, punks, sabotage, tactical chaos, provocation, anti-system' },
|
||||
{ path: '/larp/dogma', mod: 'larpMod', description: 'DogmA house, dog, thinkers, journalists, philosophers, information control, knowledge, archive, narratives, ai, memory' },
|
||||
{ path: '/larp/helix', mod: 'larpMod', description: 'HeliX house, hlx, clowns, influencers, musicians, priests, entertainment, culture, humor, celebration, ritual, joy, festival' },
|
||||
{ path: '/larp/quark', mod: 'larpMod', description: 'QuarK house, quk, athletes, soldiers, street people, protection, security, defense, survival, family, mutual aid' },
|
||||
{ path: '/larp/hermandad', mod: 'larpMod', description: 'HERmanDAD house, hrm, architects, builders, investors, industrialists, construction, development, sustainability, logistics, production, supply chain' },
|
||||
{ path: '/larp/test', mod: 'larpMod', description: 'will test, psychological test, take house test, assigned to house, find my house, profile questions' },
|
||||
{ path: '/chats', mod: 'chatsMod', description: 'chats, messaging, encrypted rooms, group conversations' },
|
||||
{ path: '/pads', mod: 'padsMod', description: 'pads, collaborative editor, shared notes, encrypted documents' },
|
||||
{ path: '/rooms', mod: 'roomsMod', description: 'rooms, audio rooms, voice rooms, group audio, talk together, live conversation, salas, salas de audio' },
|
||||
{ path: '/wiki', mod: 'wikiMod', description: 'wiki, knowledge base, shared pages, articles, documentation, glossary, versioned pages' },
|
||||
{ path: '/campaigns', mod: 'campaignsMod', description: 'campaigns, petitions, signatures, sign a petition, collect signatures, change.org, goal, supporters' },
|
||||
{ path: '/podcasts', mod: 'podcastsMod', description: 'podcasts, podcast channels, episodes, audio series, video series, listen, subscribe to a podcast, download a whole podcast' },
|
||||
{ path: '/logistics', mod: 'logisticsMod', description: 'logistics, trips, rides, carpool, ride sharing, shipments, deliveries, carry goods, move something, transport, book a seat, route ratings' },
|
||||
{ path: '/mailing', mod: 'mailingMod', description: 'mailing lists, mailing list, mailman, newsletter, list of subscribers, write to a list, list archive, threads' },
|
||||
{ path: '/emergencies', mod: 'emergenciesMod', description: 'emergencies, alerts, warnings, weather warning, power outage, water cut, health alert, security, lost and found, neighborhood notice' },
|
||||
{ path: '/calendars', mod: 'calendarsMod', description: 'calendar, events by date, schedule, reminders, recurring dates' },
|
||||
{ path: '/maps', mod: 'mapsMod', description: 'maps, locations, markers, geography, places' },
|
||||
{ path: '/events', mod: 'eventsMod', description: 'events, agenda, meetups, gatherings, RSVP' },
|
||||
{ path: '/agenda', mod: 'agendaMod', description: 'agenda, scheduled items, upcoming, my dates, my tasks events transfers projects jobs market reports tribes' },
|
||||
{ path: '/agenda?filter=discarded', mod: 'agendaMod', description: 'discarded agenda items, archived agenda, removed from agenda, hidden tasks' },
|
||||
{ path: '/tasks', mod: 'tasksMod', description: 'tasks, todo, assignments, work items, priorities' },
|
||||
{ path: '/projects', mod: 'projectsMod', description: 'projects, milestones, backers, crowdfunding, bounties' },
|
||||
{ path: '/school', mod: 'schoolMod', description: 'school, courses, classes, lessons, learning, teach, study, education, enroll, students, certificates, diploma pdf, exams, course invite code, join course with code' },
|
||||
{ path: '/industry', mod: 'industryMod', description: 'industry, facilities, factories, workshops, means of manufacturing, commons, cooperatives, blueprints, builds' },
|
||||
{ path: '/industry?filter=BLUEPRINTS', mod: 'industryMod', description: 'blueprints, designs, recipes, open hardware designs, copyleft designs, bill of materials, materials list' },
|
||||
{ path: '/industry?filter=BUILDS', mod: 'industryMod', description: 'builds, manufacturing runs, fabrication orders, batches, build status, contributions to a build' },
|
||||
{ path: '/industry?filter=MEMBER', mod: 'industryMod', description: 'facilities I belong to, my industry memberships, where I am a worker' },
|
||||
{ path: '/industry?filter=MINE', mod: 'industryMod', description: 'facilities I steward, industries I created' },
|
||||
{ path: '/industry?filter=ACTIVE', mod: 'industryMod', description: 'working facilities, active factories, industries manufacturing right now' },
|
||||
{ path: '/industry?filter=PAUSED', mod: 'industryMod', description: 'paused facilities, halted manufacturing, stopped industries' },
|
||||
{ path: '/industry?filter=DISSOLVED', mod: 'industryMod', description: 'dissolved facilities, closed factories, ended cooperatives' },
|
||||
{ path: '/industry?filter=RULES', mod: 'industryMod', description: 'industry rules, how facilities work, stewardship, quorum, majority, karma shares, distribution' },
|
||||
{ path: '/housing', mod: 'housingMod', description: 'housing, homes, flats, apartments, rooms, lodging, accommodation, real estate, places to live' },
|
||||
{ path: '/housing?filter=SALE', mod: 'housingMod', description: 'homes for sale, property for sale, real estate on sale, houses being sold' },
|
||||
{ path: '/housing?filter=RENT', mod: 'housingMod', description: 'places to rent, rentals, monthly rent, flat to let, room to rent' },
|
||||
{ path: '/housing?filter=COUCHSURFING', mod: 'housingMod', description: 'couchsurfing, free stay, hospitality, host me, sofa to sleep, guest room offered for free' },
|
||||
{ path: '/housing?filter=REQUESTED', mod: 'housingMod', description: 'places I requested, my housing requests, stays I asked for' },
|
||||
{ path: '/housing?filter=MINE', mod: 'housingMod', description: 'my places, housing listings I published, my properties' },
|
||||
{ path: '/jobs', mod: 'jobsMod', description: 'jobs, work, hiring, salaries, vacancies, applications' },
|
||||
{ path: '/market', mod: 'marketMod', description: 'market, marketplace, buy, sell, items, auctions, ECO' },
|
||||
{ path: '/shops', mod: 'shopsMod', description: 'shops, stores, products, ecommerce, vendors' },
|
||||
{ path: '/banking', mod: 'bankingMod', description: 'banking, wallet, ECO balance, send money, transfers, payments, UBI claim, karma score, eco tax penalty, ECOin value' },
|
||||
{ path: '/transfers', mod: 'transfersMod', description: 'transfers, payments, money movements, ECO transactions, history, smart contracts, contract PDF, export contract' },
|
||||
{ path: '/wallet', mod: 'walletMod', description: 'wallet, ECOin address, send and receive, QR code, balance' },
|
||||
{ path: '/parliament', mod: 'parliamentMod', description: 'parliament, governance, government, proposals, laws, leaders, voting' },
|
||||
{ path: '/courts', mod: 'courtsMod', description: 'courts, judges, accusations, mediators, justice, disputes' },
|
||||
{ path: '/votes', mod: 'votesMod', description: 'votes, votations, ballots, decisions, polling, voting, polls, surveys, multi-option votes' },
|
||||
{ path: '/opinions', mod: 'opinionsMod', description: 'opinions, reactions, ratings, sentiment, expressing views, interesting useful funny boring sad joyful angry confused inspiring frustrating curious sympathetic challenged surprised exited categories' },
|
||||
{ path: '/trending', mod: 'trendingMod', description: 'trending, popular, hot, top voted, what is being discussed, most opinions, top spread, viral content' },
|
||||
{ path: '/activity', mod: null, description: 'activity, recent actions, what is happening, my history, others actions, feed votes, opinions activity, spread activity' },
|
||||
{ path: '/activity?filter=mine', mod: null, description: 'my activity, my actions, what I did, my history, what I published' },
|
||||
{ path: '/activity?filter=today', mod: null, description: 'today activity, last 24h, recent today' },
|
||||
{ path: '/reports', mod: 'reportsMod', description: 'reports, bug reports, abuse, incidents, severity, confirmations' },
|
||||
{ path: '/audios', mod: 'audiosMod', description: 'audios, music, podcasts, voice recordings, sound files' },
|
||||
{ path: '/audios?filter=bcs', mod: 'audiosMod', description: 'BCS audios, blockchain sonification audios, melody compositions published by inhabitants, transcode source audio, embedded steganography' },
|
||||
{ path: '/audios?filter=favorites', mod: 'audiosMod', description: 'favorite audios, starred audios, saved music' },
|
||||
{ path: '/videos', mod: 'videosMod', description: 'videos, films, clips, recordings, watch' },
|
||||
{ path: '/images', mod: 'imagesMod', description: 'images, photos, pictures, gallery, memes' },
|
||||
{ path: '/documents', mod: 'docsMod', description: 'documents, PDFs, files, papers, references' },
|
||||
{ path: '/bookmarks', mod: 'bookmarksMod', description: 'bookmarks, links, saved websites, favorites' },
|
||||
{ path: '/files', mod: 'filesMod', description: 'files, any file type, uploads, downloads, binaries, archives' },
|
||||
{ path: '/torrents', mod: 'torrentsMod', description: 'torrents, magnet links, file sharing, downloads' },
|
||||
{ path: '/tags', mod: 'tagsMod', description: 'tags, hashtags, topics, categories, labels' },
|
||||
{ path: '/search', mod: null, description: 'search, find, query, lookup' },
|
||||
{ path: '/inbox', mod: null, description: 'inbox, notifications, mentions, emergencies, messages addressed to me, received PM' },
|
||||
{ path: '/inbox?filter=sent', mod: null, description: 'sent messages, outgoing PM, my sent private messages, what I wrote' },
|
||||
{ path: '/inbox?filter=reminders', mod: null, description: 'reminders, task reminders, calendar reminders, automatic notifications' },
|
||||
{ path: '/phone', mod: 'phoneMod', description: 'phone, call, calls, voice call, call someone, ring, telephone, voip, audio call, private audio message, PaM, voicemail, voice message, missed calls, call history' },
|
||||
{ path: '/inbox?filter=notifications', mod: null, description: 'bot notifications, BankingBot, PoliticalBot, MarketBot, notices per bot, unread notices' },
|
||||
{ path: '/inbox?filter=archived', mod: null, description: 'archived messages, archived PMs, put away conversations' },
|
||||
{ path: '/pm', mod: null, description: 'private messages, direct messages, DMs, encrypted PM, compose new PM' },
|
||||
{ path: '/mentions', mod: null, description: 'mentions, who mentioned me, tagged me, my mentions, posts mentioning me, tribe mentions' },
|
||||
{ path: '/games', mod: 'gamesMod', description: 'games, play, mini-games, scoring, fun' },
|
||||
{ path: '/ai', mod: 'aiMod', description: '42, AI assistant, ask the AI, chat with AI, oasis assistant, artificial intelligence, ai help, ai answers' },
|
||||
{ path: '/pixelia', mod: 'pixeliaMod', description: 'pixelia, pixel canvas, draw, collaborative pixel art' },
|
||||
{ path: '/cv', mod: null, description: 'cv, curriculum, resume, my profile, skills, experiences' },
|
||||
{ path: '/backup', mod: 'backupMod', description: 'backup, export keys, import keys, recovery kit, full copy of my data, restore on another device, encrypted backup' },
|
||||
{ path: '/cipher', mod: 'cipherMod', description: 'cipher, encrypt, decrypt, password, vault' },
|
||||
{ path: '/dev', mod: 'devMod', description: 'source code, oasis codebase, file tree, browse files, read the code' },
|
||||
{ path: '/dev/search', mod: 'devMod', description: 'grep the codebase, find a function, find a string in source files' },
|
||||
{ path: '/dev/map', mod: 'devMod', description: 'module map, which file implements a module, models views routes tests' },
|
||||
{ path: '/stats', mod: null, description: 'stats, statistics, KPIs, metrics, dashboard, carbon footprint' },
|
||||
{ path: '/peers', mod: null, description: 'peers, connections, network, nodes, who am I connected to, LAN, refresh discovery, export peer list, import peer list, remove idle' },
|
||||
{ path: '/invites', mod: 'invitesMod', description: 'invites, pub invitations, join code, follow PUB, federations, federated networks, import pubs, export pubs, unreachable pubs, redeem tribe invite code, join tribe by code, redeem course code, join school course by code, accept invitation' },
|
||||
{ path: '/graphos', mod: 'graphosMod', description: 'graphos, network map, visualization, relationship graph' },
|
||||
{ path: '/modules', mod: null, description: 'modules, features, enable disable plugins, settings' },
|
||||
{ path: '/settings', mod: null, description: 'settings, preferences, language, theme, configuration' },
|
||||
{ path: '/favorites', mod: 'favoritesMod', description: 'favorites, starred items, saved content, my bookmarks audios videos images documents files pads rooms chats calendars maps torrents' },
|
||||
{ path: '/favorites?filter=recent', mod: 'favoritesMod', description: 'recent favorites, latest starred items' },
|
||||
{ path: '/logs', mod: 'logsMod', description: 'logs, life log, personal records, journal, experiences' },
|
||||
{ path: '/melody', mod: 'melodyMod', description: 'melody, sound of my blockchain, music, generate sound, audio of blocks, sonification, publish BCS, hidden message, steganography, embed text in waveform' },
|
||||
{ path: '/melody?filter=all', mod: 'melodyMod', description: 'BCS from other inhabitants, all blockchain compositions, listen to peers melodies, transcode audio of others' },
|
||||
{ path: '/profile', mod: null, description: 'my profile, my avatar, my page, my identity, my data, my clearnet link' },
|
||||
{ path: '/profile/edit', mod: null, description: 'edit profile, edit avatar, change name, change description, visibility prefs, sensors, eco tax toggle, clearnet toggle, GPG fingerprint' },
|
||||
{ path: '/blockexplorer', mod: null, description: 'blockexplorer, blockchain, blockchain explorer, blocks, ledger, chain, carbon footprint per block, chain history' },
|
||||
{ path: '/fediverse', mod: 'fediverseMod', description: 'multiverse, fediverse, mastodon, telegram, activitypub, external social networks, federated timeline, telegram chats and channels, cross-post, fediverse handle, follow external accounts' },
|
||||
{ path: '/stats?filter=ALL', mod: null, description: 'global stats, network kpis, total carbon footprint, total inhabitants, network size' },
|
||||
{ path: '/stats?filter=MINE', mod: null, description: 'my stats, my carbon footprint, my activity numbers, personal kpis' },
|
||||
{ path: '/tribes/create', mod: 'tribesMod', description: 'create tribe, new tribe, new group, start community, create private room' },
|
||||
{ path: '/chats', mod: 'chatsMod', description: 'create chat, new chat, start conversation, new encrypted room' },
|
||||
{ path: '/pads', mod: 'padsMod', description: 'create pad, new pad, new collaborative document, start shared note' },
|
||||
{ path: '/rooms?filter=create', mod: 'roomsMod', description: 'create room, new room, open an audio room, start a voice room, crear sala' },
|
||||
{ path: '/wiki?filter=create', mod: 'wikiMod', description: 'create wiki page, new wiki page, write article, document knowledge' },
|
||||
{ path: '/campaigns?filter=CREATE', mod: 'campaignsMod', description: 'start a campaign, create a petition, collect signatures for a cause' },
|
||||
{ path: '/podcasts?filter=CREATE', mod: 'podcastsMod', description: 'create podcast, new podcast channel, start a podcast, publish episodes' },
|
||||
{ path: '/logistics?filter=CREATE', mod: 'logisticsMod', description: 'offer a ride, request a ride, offer transport, need to move something, publish a shipment, new route' },
|
||||
{ path: '/mailing?filter=CREATE', mod: 'mailingMod', description: 'create mailing list, new mailing list, open a list, closed encrypted list' },
|
||||
{ path: '/emergencies?filter=CREATE', mod: 'emergenciesMod', description: 'create emergency, raise emergency, raise alert, warn the network, report outage' },
|
||||
{ path: '/calendars', mod: 'calendarsMod', description: 'create calendar, new calendar, start schedule' },
|
||||
{ path: '/maps', mod: 'mapsMod', description: 'create map, new map, new offline map' },
|
||||
{ path: '/events', mod: 'eventsMod', description: 'create event, new event, schedule meetup' },
|
||||
{ path: '/projects', mod: 'projectsMod', description: 'create project, new project, start crowdfunding' },
|
||||
{ path: '/school?filter=create', mod: 'schoolMod', description: 'create course, new course, teach a class, offer lessons' },
|
||||
{ path: '/industry?filter=CREATE', mod: 'industryMod', description: 'create facility, new factory, new workshop, found a cooperative, means of manufacturing' },
|
||||
{ path: '/housing?filter=CREATE', mod: 'housingMod', description: 'publish a place, offer accommodation, list a home, new housing listing, host someone' },
|
||||
{ path: '/jobs', mod: 'jobsMod', description: 'create job, post job offer, new vacancy, hire' },
|
||||
{ path: '/market', mod: 'marketMod', description: 'create market item, sell something, new auction, list for sale' },
|
||||
{ path: '/shops', mod: 'shopsMod', description: 'create shop, open store, new vendor, list products' },
|
||||
{ path: '/tasks', mod: 'tasksMod', description: 'create task, new todo, new assignment' },
|
||||
{ path: '/reports', mod: 'reportsMod', description: 'create report, file bug, report issue, report abuse' }
|
||||
]
|
||||
|
||||
const CACHE_FILE = path.join(__dirname, 'embeddings', 'routes_cache.json')
|
||||
|
||||
let cache = null
|
||||
|
||||
const buildCacheKey = () => ROUTES.map(r => r.path + '|' + r.description).join('\n')
|
||||
|
||||
const loadCache = () => {
|
||||
try {
|
||||
if (!fs.existsSync(CACHE_FILE)) return null
|
||||
const data = JSON.parse(fs.readFileSync(CACHE_FILE, 'utf8'))
|
||||
if (data.key === buildCacheKey() && Array.isArray(data.entries)) return data.entries
|
||||
return null
|
||||
} catch (_) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const saveCache = (entries) => {
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(CACHE_FILE), { recursive: true })
|
||||
fs.writeFileSync(CACHE_FILE, JSON.stringify({ key: buildCacheKey(), entries }, null, 2), 'utf8')
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
const ensureIndex = async ({ embed }) => {
|
||||
if (cache) return cache
|
||||
const cached = loadCache()
|
||||
if (cached) { cache = cached; return cache }
|
||||
const entries = []
|
||||
for (const r of ROUTES) {
|
||||
const vec = await embed(r.description)
|
||||
if (!vec) return null
|
||||
entries.push({ path: r.path, mod: r.mod, vector: vec })
|
||||
}
|
||||
cache = entries
|
||||
saveCache(entries)
|
||||
return cache
|
||||
}
|
||||
|
||||
const dot = (a, b) => {
|
||||
let s = 0
|
||||
const n = Math.min(a.length, b.length)
|
||||
for (let i = 0; i < n; i++) s += a[i] * b[i]
|
||||
return s
|
||||
}
|
||||
|
||||
const descriptionByPath = (p) => {
|
||||
const r = ROUTES.find(x => x.path === p)
|
||||
return r ? r.description : ''
|
||||
}
|
||||
|
||||
const dedupeByPath = (list) => {
|
||||
const seen = new Set()
|
||||
const out = []
|
||||
for (const item of list) {
|
||||
if (seen.has(item.path)) continue
|
||||
seen.add(item.path)
|
||||
out.push(item)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
const resolveBest = async (queryVector, { isModuleEnabled, threshold = 0.4, embed } = {}) => {
|
||||
const idx = await ensureIndex({ embed })
|
||||
if (!idx) return null
|
||||
let best = null
|
||||
for (const entry of idx) {
|
||||
if (entry.mod && typeof isModuleEnabled === 'function' && !isModuleEnabled(entry.mod)) continue
|
||||
const score = dot(queryVector, entry.vector)
|
||||
if (!best || score > best.score) best = { path: entry.path, score }
|
||||
}
|
||||
if (!best || best.score < threshold) return null
|
||||
return best
|
||||
}
|
||||
|
||||
const resolveTopK = async (queryVector, { isModuleEnabled, threshold = 0.35, embed } = {}, k = 5) => {
|
||||
const idx = await ensureIndex({ embed })
|
||||
if (!idx) return []
|
||||
const all = []
|
||||
for (const entry of idx) {
|
||||
if (entry.mod && typeof isModuleEnabled === 'function' && !isModuleEnabled(entry.mod)) continue
|
||||
const score = dot(queryVector, entry.vector)
|
||||
if (score < threshold) continue
|
||||
all.push({ path: entry.path, mod: entry.mod, score, description: descriptionByPath(entry.path) })
|
||||
}
|
||||
all.sort((a, b) => b.score - a.score)
|
||||
return dedupeByPath(all).slice(0, Math.max(1, k|0))
|
||||
}
|
||||
|
||||
const KEYWORD_STOPWORDS = new Set([
|
||||
'the', 'this', 'that', 'these', 'those', 'and', 'for', 'with', 'from', 'into', 'about', 'want', 'show', 'see', 'find', 'give', 'get', 'go', 'my', 'me', 'to', 'of', 'in', 'on', 'at', 'an', 'is', 'are', 'do', 'does',
|
||||
'week', 'year', 'month', 'today', 'tomorrow', 'now', 'next', 'last',
|
||||
'el', 'la', 'los', 'las', 'un', 'una', 'unos', 'unas', 'de', 'del', 'este', 'esta', 'esto', 'estos', 'estas', 'ese', 'esa', 'eso', 'que', 'quiero', 'ver', 'buscar', 'dame', 'ir', 'mi', 'mis', 'lo', 'se', 'al', 'en', 'con', 'para', 'por',
|
||||
'semana', 'mes', 'hoy', 'ahora', 'proximo', 'próximo', 'proxima', 'próxima', 'ultimo', 'último', 'ultima', 'última',
|
||||
'els', 'les', 'uns', 'unes', 'dels', 'aquest', 'aquesta', 'aquests', 'aquestes', 'això', 'aquell', 'aquella', 'vull', 'veure', 'cercar', 'anar', 'meu', 'meva', 'meus', 'meves', 'amb', 'per', 'als', 'on', 'què', 'com',
|
||||
'setmana', 'avui', 'ara', 'proper', 'propera', 'darrer', 'darrera',
|
||||
'os', 'as', 'unha', 'unhas', 'do', 'da', 'dos', 'das', 'isto', 'iso', 'estes', 'quero', 'miña', 'miñas', 'meu', 'meus', 'no', 'na', 'nos', 'nas', 'co', 'coa', 'onde', 'como',
|
||||
'hoxe', 'agora'
|
||||
])
|
||||
|
||||
const resolveKeywordTopK = ({ isModuleEnabled } = {}, query, k = 8) => {
|
||||
let tokens = String(query || '').toLowerCase().split(/[^a-z0-9À-ſ]+/).filter(t => t && t.length >= 2)
|
||||
const meaningful = tokens.filter(t => !KEYWORD_STOPWORDS.has(t))
|
||||
if (meaningful.length) tokens = meaningful
|
||||
if (!tokens.length) return []
|
||||
const all = []
|
||||
for (const entry of ROUTES) {
|
||||
if (entry.mod && typeof isModuleEnabled === 'function' && !isModuleEnabled(entry.mod)) continue
|
||||
const haystack = (entry.description || '').toLowerCase() + ' ' + entry.path.toLowerCase()
|
||||
let hits = 0
|
||||
for (const t of tokens) {
|
||||
if (haystack.includes(t)) hits += 1
|
||||
}
|
||||
if (hits === 0) continue
|
||||
all.push({ path: entry.path, mod: entry.mod, score: hits / tokens.length, description: entry.description })
|
||||
}
|
||||
all.sort((a, b) => b.score - a.score)
|
||||
return dedupeByPath(all).slice(0, Math.max(1, k|0))
|
||||
}
|
||||
|
||||
module.exports = { ROUTES, ensureIndex, resolveBest, resolveTopK, resolveKeywordTopK }
|
||||
|
|
@ -1,77 +0,0 @@
|
|||
const fs = require('fs')
|
||||
const { readTyped } = require('../models/typed_log')
|
||||
const { buildValidatedTombstoneSet } = require('../models/tombstone_validator')
|
||||
const { versionGroups, versionEntry } = require('../backend/dedupe')
|
||||
|
||||
const VECTORS_FILE = 'AI-search-vectors.json'
|
||||
const MAX_ITEMS = 4000
|
||||
const MIN_SCORE = 0.42
|
||||
const TEXT_MAX = 400
|
||||
|
||||
const TYPES = ['post', 'event', 'job', 'project', 'market', 'shop', 'shopProduct', 'tribe', 'task', 'wikiPage', 'document', 'audio', 'video', 'image', 'bookmark', 'forum', 'campaign', 'emergency', 'podcast', 'schoolCourse', 'housing', 'logisticsRoute', 'mailingList', 'room', 'curriculum', 'blog', 'report', 'poll', 'torrent', 'file']
|
||||
|
||||
const vectorsPath = () => { try { return require('../configs/state-manager').statePath(VECTORS_FILE) } catch (_) { return null } }
|
||||
const readVectors = () => { try { const j = JSON.parse(fs.readFileSync(vectorsPath(), 'utf8')); return j && typeof j === 'object' ? j : {} } catch (_) { return {} } }
|
||||
const writeVectors = (map) => { try { const p = vectorsPath(); if (p) fs.writeFileSync(p, JSON.stringify(map)) } catch (_) {} }
|
||||
|
||||
const squash = (s) => String(s || '').replace(/\s+/g, ' ').trim()
|
||||
const textOf = (c) => {
|
||||
if (!c || typeof c !== 'object') return ''
|
||||
if (c.encryptedPayload || c.encryptedQuestion) return ''
|
||||
const title = squash(c.title || c.name || c.question || c.subject || c.concept || '')
|
||||
const body = squash(c.description || c.text || c.body || c.contentWarning || '')
|
||||
const tags = Array.isArray(c.tags) ? c.tags.map(t => String(t || '')).join(' ') : ''
|
||||
return `${title} ${body} ${tags}`.trim().slice(0, TEXT_MAX)
|
||||
}
|
||||
|
||||
let indexing = null
|
||||
|
||||
const indexOnce = async (ssb, { embed, limit }) => {
|
||||
const msgs = await readTyped(ssb, [...TYPES, 'tombstone'], { limit }).catch(() => [])
|
||||
const tomb = buildValidatedTombstoneSet(msgs)
|
||||
const content = msgs.filter(m => m && m.key && m.value && m.value.content && TYPES.includes(m.value.content.type))
|
||||
const byKey = new Map(content.map(m => [m.key, m]))
|
||||
const vg = versionGroups(content.map(versionEntry))
|
||||
const usable = [...vg.roots].map(r => byKey.get(vg.tipOf(r)))
|
||||
.filter(m => m && !tomb.has(m.key) && textOf(m.value.content))
|
||||
.sort((a, b) => ((a.value.timestamp || 0) - (b.value.timestamp || 0)))
|
||||
const newest = usable.slice(-MAX_ITEMS)
|
||||
const cache = readVectors()
|
||||
let dirty = false
|
||||
const items = []
|
||||
for (const m of newest) {
|
||||
let v = cache[m.key]
|
||||
if (!Array.isArray(v)) {
|
||||
v = await embed(textOf(m.value.content)).catch(() => null)
|
||||
if (!v) continue
|
||||
cache[m.key] = v
|
||||
dirty = true
|
||||
}
|
||||
items.push({ msg: m, vector: v })
|
||||
}
|
||||
if (dirty) {
|
||||
const keep = new Set(newest.map(m => m.key))
|
||||
for (const key of Object.keys(cache)) if (!keep.has(key)) delete cache[key]
|
||||
writeVectors(cache)
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
const search = async (ssb, query, { embed, cosine, limit = 1000, k = 20 } = {}) => {
|
||||
if (typeof embed !== 'function' || typeof cosine !== 'function' || !ssb) return []
|
||||
const q = squash(query)
|
||||
if (!q) return []
|
||||
const qv = await embed(q).catch(() => null)
|
||||
if (!qv) return []
|
||||
if (!indexing) indexing = indexOnce(ssb, { embed, limit }).finally(() => { indexing = null })
|
||||
const items = await indexing.catch(() => [])
|
||||
const scored = []
|
||||
for (const it of items) {
|
||||
const s = cosine(qv, it.vector)
|
||||
if (s >= MIN_SCORE) scored.push({ msg: it.msg, score: s })
|
||||
}
|
||||
scored.sort((a, b) => b.score - a.score)
|
||||
return scored.slice(0, k)
|
||||
}
|
||||
|
||||
module.exports = { search, textOf, TYPES, MIN_SCORE }
|
||||
|
|
@ -1,74 +0,0 @@
|
|||
/**
|
||||
* Identidades locales.
|
||||
*
|
||||
* Cada identidad vive en su propio directorio ~/.<cuenta>, con su secret, su base
|
||||
* de datos y sus blobs. Cual esta activa lo decide ssb_config.js al arrancar:
|
||||
* primero OASIS_ACCOUNT, y si no, el fichero que escribe setActive().
|
||||
*
|
||||
* Aqui no se cambia de identidad en caliente: medio Oasis captura la referencia al
|
||||
* sbot al cargarse, asi que cambiarla con el proceso vivo dejaria modulos hablando
|
||||
* con la identidad anterior. El cambio se aplica al reiniciar.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
|
||||
// mismo criterio que ssb_config.js: este nombre acaba en rutas de fichero
|
||||
const ACCOUNT_RE = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,31}$/;
|
||||
const ACTIVE_FILE = path.join(__dirname, '..', 'configs', 'active-account');
|
||||
|
||||
const isValid = (name) => typeof name === 'string' && ACCOUNT_RE.test(name);
|
||||
|
||||
/** Cuenta anotada como activa, o null si no hay ninguna anotada. */
|
||||
const readActive = () => {
|
||||
try {
|
||||
const raw = fs.readFileSync(ACTIVE_FILE, 'utf8').trim();
|
||||
return isValid(raw) ? raw : null;
|
||||
} catch (_) { return null; }
|
||||
};
|
||||
|
||||
/** Anota cual sera la cuenta activa en el proximo arranque. */
|
||||
const setActive = (name) => {
|
||||
if (!isValid(name)) throw new Error('nombre de cuenta no valido');
|
||||
fs.mkdirSync(path.dirname(ACTIVE_FILE), { recursive: true });
|
||||
fs.writeFileSync(ACTIVE_FILE, name, { mode: 0o600 });
|
||||
return name;
|
||||
};
|
||||
|
||||
/** Directorio de una cuenta. */
|
||||
const dirOf = (name) => path.join(os.homedir(), `.${name}`);
|
||||
|
||||
/**
|
||||
* Identidades que existen en el disco: directorios ~/.<algo> con un secret dentro.
|
||||
* El secret es lo que hace que un directorio sea una identidad y no otra cosa.
|
||||
*/
|
||||
const list = () => {
|
||||
let entries = [];
|
||||
try {
|
||||
entries = fs.readdirSync(os.homedir(), { withFileTypes: true });
|
||||
} catch (_) { return []; }
|
||||
|
||||
return entries
|
||||
.filter((e) => e.isDirectory() && e.name.startsWith('.'))
|
||||
.map((e) => e.name.slice(1))
|
||||
.filter(isValid)
|
||||
.filter((name) => {
|
||||
try { return fs.statSync(path.join(dirOf(name), 'secret')).isFile(); }
|
||||
catch (_) { return false; }
|
||||
})
|
||||
.sort();
|
||||
};
|
||||
|
||||
/** Identidad publica (@...) de una cuenta, leyendo su secret. Null si no se puede. */
|
||||
const feedIdOf = (name) => {
|
||||
if (!isValid(name)) return null;
|
||||
try {
|
||||
const raw = fs.readFileSync(path.join(dirOf(name), 'secret'), 'utf8');
|
||||
const clean = raw.split('\n').filter((l) => !l.trim().startsWith('#')).join('\n');
|
||||
const id = JSON.parse(clean).id;
|
||||
return typeof id === 'string' ? id : null;
|
||||
} catch (_) { return null; }
|
||||
};
|
||||
|
||||
module.exports = { isValid, list, readActive, setActive, feedIdOf, dirOf, ACTIVE_FILE };
|
||||
11696
src/backend/backend.js
|
|
@ -5,202 +5,19 @@ const ssb = require("../client/gui");
|
|||
const config = require("../server/SSB_server").config;
|
||||
const cooler = ssb({ offline: config.offline });
|
||||
|
||||
let sharpLib;
|
||||
const getSharp = () => {
|
||||
if (sharpLib === undefined) {
|
||||
try { sharpLib = require('../server/node_modules/sharp'); } catch (_) { sharpLib = null; }
|
||||
}
|
||||
return sharpLib;
|
||||
};
|
||||
|
||||
/**
|
||||
* Quitar metadatos de una imagen SIN sharp.
|
||||
*
|
||||
* sharp no se empaqueta en Android (lleva binarios nativos y el bundle del movil
|
||||
* va sin ninguno), asi que alli esta funcion devolvia la imagen intacta y las
|
||||
* fotos subian con su EXIF entero, ubicacion GPS incluida.
|
||||
*
|
||||
* Esto recorre el contenedor y tira los bloques de metadatos, sin decodificar ni
|
||||
* recomprimir la imagen: los pixeles se quedan byte a byte como estaban.
|
||||
*
|
||||
* De la orientacion se guarda solo eso, la orientacion. Sin sharp no se pueden
|
||||
* girar los pixeles, y tirar la etiqueta dejaria las fotos verticales tumbadas;
|
||||
* asi que se reconstruye un bloque EXIF minimo con esa unica etiqueta, que no
|
||||
* dice nada de quien hizo la foto ni donde.
|
||||
*/
|
||||
|
||||
// JPEG: cada segmento es FF <marca> <longitud:2> <datos>. Se tiran los que llevan
|
||||
// metadatos y se dejan JFIF (APP0) y el perfil de color (APP2), que afectan a como
|
||||
// se ve la imagen.
|
||||
const JPEG_DROP = new Set([
|
||||
0xE1, // APP1 Exif y XMP
|
||||
0xE5, // APP5
|
||||
0xE6, // APP6
|
||||
0xEC, // APP12 Ducky/Picture Info
|
||||
0xED, // APP13 IPTC / Photoshop
|
||||
0xEE, // APP14 Adobe
|
||||
0xFE // COM comentario
|
||||
]);
|
||||
|
||||
const jpegOrientation = (seg) => {
|
||||
// seg empieza en "Exif\0\0"; detras va una cabecera TIFF con la IFD0
|
||||
if (seg.length < 14 || seg.toString("latin1", 0, 6) !== "Exif\0\0") return 0;
|
||||
const tiff = seg.subarray(6);
|
||||
const le = tiff.toString("latin1", 0, 2) === "II";
|
||||
const u16 = (o) => (le ? tiff.readUInt16LE(o) : tiff.readUInt16BE(o));
|
||||
const u32 = (o) => (le ? tiff.readUInt32LE(o) : tiff.readUInt32BE(o));
|
||||
if (u16(2) !== 0x002A) return 0;
|
||||
const ifd0 = u32(4);
|
||||
if (ifd0 + 2 > tiff.length) return 0;
|
||||
const n = u16(ifd0);
|
||||
for (let i = 0; i < n; i++) {
|
||||
const e = ifd0 + 2 + i * 12;
|
||||
if (e + 12 > tiff.length) break;
|
||||
if (u16(e) === 0x0112) { // Orientation
|
||||
const v = u16(e + 8);
|
||||
return v >= 1 && v <= 8 ? v : 0;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
/** Bloque APP1 de 32 bytes con la orientacion y nada mas. */
|
||||
const exifOnlyOrientation = (orientation) => {
|
||||
const b = Buffer.alloc(32);
|
||||
b.write("Exif\0\0", 0, "latin1");
|
||||
b.write("II", 6, "latin1");
|
||||
b.writeUInt16LE(0x002A, 8);
|
||||
b.writeUInt32LE(8, 10); // la IFD0 empieza justo detras de la cabecera TIFF
|
||||
b.writeUInt16LE(1, 14); // una sola entrada
|
||||
b.writeUInt16LE(0x0112, 16); // Orientation
|
||||
b.writeUInt16LE(3, 18); // SHORT
|
||||
b.writeUInt32LE(1, 20); // un valor
|
||||
b.writeUInt16LE(orientation, 24);
|
||||
b.writeUInt32LE(0, 28); // no hay mas IFDs
|
||||
return b;
|
||||
};
|
||||
|
||||
const stripJpeg = (buf) => {
|
||||
if (buf.length < 4 || buf[0] !== 0xFF || buf[1] !== 0xD8) return null;
|
||||
const out = [buf.subarray(0, 2)];
|
||||
let orientation = 0;
|
||||
let i = 2;
|
||||
let scan = false;
|
||||
while (i + 4 <= buf.length) {
|
||||
if (buf[i] !== 0xFF) return null; // no es un JPEG sano: no tocarlo
|
||||
const marker = buf[i + 1];
|
||||
if (marker === 0xD8 || (marker >= 0xD0 && marker <= 0xD9)) { i += 2; continue; }
|
||||
if (marker === 0xDA) { out.push(buf.subarray(i)); scan = true; break; } // datos comprimidos: hasta el final
|
||||
const len = buf.readUInt16BE(i + 2);
|
||||
if (len < 2 || i + 2 + len > buf.length) return null;
|
||||
const seg = buf.subarray(i + 4, i + 2 + len);
|
||||
if (marker === 0xE1 && !orientation) orientation = jpegOrientation(seg);
|
||||
if (!JPEG_DROP.has(marker)) out.push(buf.subarray(i, i + 2 + len));
|
||||
i += 2 + len;
|
||||
}
|
||||
// sin datos de imagen (SOS) es un JPEG cortado: devolver la cabecera sola subiria una foto vacia
|
||||
if (!scan) return null;
|
||||
if (orientation > 1) {
|
||||
const exif = exifOnlyOrientation(orientation);
|
||||
const head = Buffer.alloc(4);
|
||||
head.writeUInt16BE(0xFFE1, 0);
|
||||
head.writeUInt16BE(exif.length + 2, 2);
|
||||
out.splice(1, 0, head, exif);
|
||||
}
|
||||
return Buffer.concat(out);
|
||||
};
|
||||
|
||||
// PNG: cadena de bloques <longitud:4> <tipo:4> <datos> <crc:4>.
|
||||
const PNG_DROP = new Set(["tEXt", "zTXt", "iTXt", "eXIf", "tIME", "dSIG"]);
|
||||
|
||||
const stripPng = (buf) => {
|
||||
const SIG = Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]);
|
||||
if (buf.length < 8 || !buf.subarray(0, 8).equals(SIG)) return null;
|
||||
const out = [buf.subarray(0, 8)];
|
||||
let i = 8;
|
||||
while (i + 8 <= buf.length) {
|
||||
const len = buf.readUInt32BE(i);
|
||||
const type = buf.toString("latin1", i + 4, i + 8);
|
||||
const end = i + 12 + len;
|
||||
if (end > buf.length) return null;
|
||||
if (!PNG_DROP.has(type)) out.push(buf.subarray(i, end));
|
||||
i = end;
|
||||
if (type === "IEND") break;
|
||||
}
|
||||
return Buffer.concat(out);
|
||||
};
|
||||
|
||||
// WebP: contenedor RIFF con bloques <tipo:4> <longitud:4> <datos> (+1 de relleno
|
||||
// si la longitud es impar).
|
||||
const stripWebp = (buf) => {
|
||||
if (buf.length < 12) return null;
|
||||
if (buf.toString("latin1", 0, 4) !== "RIFF" || buf.toString("latin1", 8, 12) !== "WEBP") return null;
|
||||
const out = [];
|
||||
let i = 12;
|
||||
while (i + 8 <= buf.length) {
|
||||
const type = buf.toString("latin1", i, i + 4);
|
||||
const len = buf.readUInt32LE(i + 4);
|
||||
const end = i + 8 + len + (len % 2);
|
||||
if (end > buf.length) return null;
|
||||
if (type !== "EXIF" && type !== "XMP ") out.push(buf.subarray(i, end));
|
||||
i = end;
|
||||
}
|
||||
const body = Buffer.concat(out);
|
||||
const head = Buffer.alloc(12);
|
||||
head.write("RIFF", 0, "latin1");
|
||||
head.writeUInt32LE(body.length + 4, 4);
|
||||
head.write("WEBP", 8, "latin1");
|
||||
return Buffer.concat([head, body]);
|
||||
};
|
||||
|
||||
// Formatos que pueden llevar EXIF (GPS incluido) y que aqui no se saben limpiar:
|
||||
// HEIC/HEIF/AVIF (caja ftyp) y TIFF. Antes subian tal cual, con la ubicacion.
|
||||
const isUncleanable = (buf) => {
|
||||
if (buf.length >= 12 && buf.toString("latin1", 4, 8) === "ftyp") {
|
||||
return /^(heic|heix|hevc|hevx|heim|heis|mif1|msf1|avif|avis)$/.test(buf.toString("latin1", 8, 12));
|
||||
}
|
||||
return buf.length >= 4 && (buf.toString("latin1", 0, 4) === "II*\0" || buf.toString("latin1", 0, 4) === "MM\0*");
|
||||
};
|
||||
const looksCleanable = (buf) =>
|
||||
(buf.length >= 2 && buf[0] === 0xFF && buf[1] === 0xD8) ||
|
||||
(buf.length >= 8 && buf.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]))) ||
|
||||
(buf.length >= 12 && buf.toString("latin1", 0, 4) === "RIFF" && buf.toString("latin1", 8, 12) === "WEBP");
|
||||
|
||||
const unsafe = () => {
|
||||
let msg = "This image could not be cleaned of its metadata, so it has not been uploaded.";
|
||||
try { msg = require("../views/main_views").i18n.imageMetadataUnsafe || msg; } catch (_) {}
|
||||
const err = new Error(msg);
|
||||
err.status = 400;
|
||||
return err;
|
||||
};
|
||||
|
||||
/**
|
||||
* Limpia segun el formato. Si es JPEG/PNG/WebP y no se puede limpiar, o es un
|
||||
* formato con EXIF que no sabemos limpiar, LANZA: es mejor no subir la foto que
|
||||
* subirla con la ubicacion. Formatos sin metadatos de ese tipo (BMP, SVG, ICO...)
|
||||
* pasan tal cual.
|
||||
*/
|
||||
const stripMetadataPure = (buffer) => {
|
||||
if (isUncleanable(buffer)) throw unsafe();
|
||||
if (!looksCleanable(buffer)) return buffer;
|
||||
let out = null;
|
||||
try { out = stripJpeg(buffer) || stripPng(buffer) || stripWebp(buffer); } catch (_) { out = null; }
|
||||
if (!out || !out.length) throw unsafe();
|
||||
return out;
|
||||
};
|
||||
let sharp;
|
||||
try {
|
||||
sharp = require("sharp");
|
||||
} catch (e) {
|
||||
}
|
||||
|
||||
const stripImageMetadata = async (buffer) => {
|
||||
// Con sharp se prefiere su camino: ademas de quitar metadatos, aplica la
|
||||
// orientacion girando los pixeles de verdad.
|
||||
const sharp = getSharp();
|
||||
if (sharp) {
|
||||
try {
|
||||
return await sharp(buffer).rotate().toBuffer();
|
||||
} catch {
|
||||
return stripMetadataPure(buffer);
|
||||
}
|
||||
if (typeof sharp !== "function") return buffer;
|
||||
try {
|
||||
return await sharp(buffer).rotate().toBuffer();
|
||||
} catch {
|
||||
return buffer;
|
||||
}
|
||||
return stripMetadataPure(buffer);
|
||||
};
|
||||
|
||||
const PDF_METADATA_KEYS = [
|
||||
|
|
@ -230,7 +47,7 @@ const stripPdfMetadata = (buffer) => {
|
|||
}
|
||||
};
|
||||
|
||||
const MAX_BLOB_SIZE = 75 * 1024 * 1024;
|
||||
const MAX_BLOB_SIZE = 50 * 1024 * 1024;
|
||||
|
||||
class FileTooLargeError extends Error {
|
||||
constructor(fileName, fileSize) {
|
||||
|
|
@ -241,32 +58,25 @@ class FileTooLargeError extends Error {
|
|||
}
|
||||
}
|
||||
|
||||
const OGG_VIDEO_MARKERS = ['theora', 'VP80', 'OggDS', 'Dirac'];
|
||||
const OGG_AUDIO_MARKERS = ['vorbis', 'OpusHead', 'FLAC', 'Speex'];
|
||||
const handleBlobUpload = async function (ctx, fileFieldName) {
|
||||
if (!ctx.request.files || !ctx.request.files[fileFieldName]) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const oggMime = (buffer) => {
|
||||
const head = buffer.slice(0, 65536);
|
||||
for (const marker of OGG_VIDEO_MARKERS) if (head.includes(Buffer.from(marker))) return 'video/ogg';
|
||||
for (const marker of OGG_AUDIO_MARKERS) if (head.includes(Buffer.from(marker))) return 'audio/ogg';
|
||||
return 'video/ogg';
|
||||
};
|
||||
|
||||
const sealedRef = (sealed, mime, name) => `/blob/sealed/${encodeURIComponent(sealed.manifestBlobId)}?k=${encodeURIComponent(sealed.key)}&t=${encodeURIComponent(mime)}&n=${encodeURIComponent(name)}`;
|
||||
|
||||
const storeUploadedFile = async function (blobUpload, seal = null) {
|
||||
if (!blobUpload || !blobUpload.filepath) return null;
|
||||
const blobUpload = ctx.request.files[fileFieldName];
|
||||
if (!blobUpload) return null;
|
||||
|
||||
let data = await promisesFs.readFile(blobUpload.filepath);
|
||||
if (data.length === 0) return null;
|
||||
|
||||
if (data.length > MAX_BLOB_SIZE) {
|
||||
throw new FileTooLargeError(blobUpload.originalFilename || blobUpload.name || 'file', data.length);
|
||||
throw new FileTooLargeError(blobUpload.originalFilename || blobUpload.name || fileFieldName, data.length);
|
||||
}
|
||||
|
||||
const EXTENSION_MIME_MAP = {
|
||||
'.mp4': 'video/mp4', '.webm': 'video/webm', '.ogg': 'video/ogg',
|
||||
'.ogv': 'video/ogg', '.oga': 'audio/ogg', '.mkv': 'video/x-matroska', '.avi': 'video/x-msvideo', '.mov': 'video/quicktime',
|
||||
'.mp3': 'audio/mpeg', '.wav': 'audio/wav',
|
||||
'.ogv': 'video/ogg', '.avi': 'video/x-msvideo', '.mov': 'video/quicktime',
|
||||
'.mkv': 'video/x-matroska', '.mp3': 'audio/mpeg', '.wav': 'audio/wav',
|
||||
'.flac': 'audio/flac', '.aac': 'audio/aac', '.opus': 'audio/opus',
|
||||
'.pdf': 'application/pdf', '.png': 'image/png', '.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp',
|
||||
|
|
@ -283,12 +93,9 @@ const storeUploadedFile = async function (blobUpload, seal = null) {
|
|||
blob.mime = null;
|
||||
}
|
||||
|
||||
if (blob.mime === 'application/ogg') blob.mime = oggMime(data);
|
||||
|
||||
const GENERIC_MIMES = new Set(['application/octet-stream', 'video/x-matroska']);
|
||||
if ((!blob.mime || GENERIC_MIMES.has(blob.mime)) && blob.name) {
|
||||
if (!blob.mime && blob.name) {
|
||||
const ext = (blob.name.match(/\.[^.]+$/) || [''])[0].toLowerCase();
|
||||
blob.mime = EXTENSION_MIME_MAP[ext] || blob.mime || 'application/octet-stream';
|
||||
blob.mime = EXTENSION_MIME_MAP[ext] || 'application/octet-stream';
|
||||
}
|
||||
|
||||
if (!blob.mime) {
|
||||
|
|
@ -301,19 +108,14 @@ const storeUploadedFile = async function (blobUpload, seal = null) {
|
|||
data = stripPdfMetadata(data);
|
||||
}
|
||||
|
||||
if (typeof seal === 'function') {
|
||||
await promisesFs.writeFile(blobUpload.filepath, data);
|
||||
const sealed = await seal({ filepath: blobUpload.filepath, size: data.length, name: blob.name, mime: blob.mime });
|
||||
blob.id = sealedRef(sealed, blob.mime, blob.name);
|
||||
} else {
|
||||
const ssbClient = await cooler.open();
|
||||
blob.id = await new Promise((resolve, reject) => {
|
||||
pull(
|
||||
pull.values([data]),
|
||||
ssbClient.blobs.add((err, ref) => (err ? reject(err) : resolve(ref)))
|
||||
);
|
||||
});
|
||||
}
|
||||
const ssbClient = await cooler.open();
|
||||
|
||||
blob.id = await new Promise((resolve, reject) => {
|
||||
pull(
|
||||
pull.values([data]),
|
||||
ssbClient.blobs.add((err, ref) => (err ? reject(err) : resolve(ref)))
|
||||
);
|
||||
});
|
||||
|
||||
if (blob.mime.startsWith("image/")) return `\n`;
|
||||
if (blob.mime.startsWith("audio/")) return `\n[audio:${blob.name}](${blob.id})`;
|
||||
|
|
@ -324,25 +126,6 @@ const storeUploadedFile = async function (blobUpload, seal = null) {
|
|||
return `\n[${blob.name}](${blob.id})`;
|
||||
};
|
||||
|
||||
const handleBlobUpload = async function (ctx, fileFieldName, seal = null) {
|
||||
if (!ctx.request.files || !ctx.request.files[fileFieldName]) return null;
|
||||
const entry = ctx.request.files[fileFieldName];
|
||||
const first = Array.isArray(entry) ? entry[0] : entry;
|
||||
return storeUploadedFile(first, seal);
|
||||
};
|
||||
|
||||
const handleBlobUploads = async function (ctx, fileFieldName, max = 8, seal = null) {
|
||||
if (!ctx.request.files || !ctx.request.files[fileFieldName]) return [];
|
||||
const entry = ctx.request.files[fileFieldName];
|
||||
const files = (Array.isArray(entry) ? entry : [entry]).slice(0, Math.max(0, max));
|
||||
const out = [];
|
||||
for (const file of files) {
|
||||
const stored = await storeUploadedFile(file, seal);
|
||||
if (stored) out.push(stored);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
function waitForBlob(ssbClient, blobId, timeoutMs = 8000) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let done = false;
|
||||
|
|
@ -385,52 +168,6 @@ function waitForBlob(ssbClient, blobId, timeoutMs = 8000) {
|
|||
});
|
||||
}
|
||||
|
||||
const contentDisposition = (type, name) => {
|
||||
const clean = String(name || 'file').replace(/["\r\n\\/]/g, '').trim() || 'file';
|
||||
const ascii = clean.replace(/[^\x20-\x7e]/g, '_');
|
||||
if (ascii === clean) return `${type}; filename="${clean}"`;
|
||||
return `${type}; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(clean).replace(/['()*]/g, (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase())}`;
|
||||
};
|
||||
|
||||
const sendBlobBuffer = (ctx, buffer) => {
|
||||
const size = buffer.length;
|
||||
const range = ctx.headers.range;
|
||||
|
||||
if (range) {
|
||||
const match = /^bytes=(\d*)-(\d*)$/.exec(range);
|
||||
if (!match) {
|
||||
ctx.status = 416;
|
||||
ctx.set('Content-Range', `bytes */${size}`);
|
||||
return;
|
||||
}
|
||||
|
||||
let start = match[1] ? parseInt(match[1], 10) : 0;
|
||||
let end = match[2] ? parseInt(match[2], 10) : size - 1;
|
||||
|
||||
if (Number.isNaN(start) || start < 0) start = 0;
|
||||
if (Number.isNaN(end) || end >= size) end = size - 1;
|
||||
|
||||
if (start > end || start >= size) {
|
||||
ctx.status = 416;
|
||||
ctx.set('Content-Range', `bytes */${size}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const chunk = buffer.slice(start, end + 1);
|
||||
|
||||
ctx.status = 206;
|
||||
ctx.set('Content-Range', `bytes ${start}-${end}/${size}`);
|
||||
ctx.set('Accept-Ranges', 'bytes');
|
||||
ctx.set('Content-Length', String(chunk.length));
|
||||
ctx.body = chunk;
|
||||
} else {
|
||||
ctx.status = 200;
|
||||
ctx.set('Accept-Ranges', 'bytes');
|
||||
ctx.set('Content-Length', String(size));
|
||||
ctx.body = buffer;
|
||||
}
|
||||
};
|
||||
|
||||
const serveBlob = async function (ctx) {
|
||||
const encodedParam = (ctx.params.id || ctx.params.blobId || '').trim();
|
||||
const raw = decodeURIComponent(encodedParam);
|
||||
|
|
@ -470,6 +207,8 @@ const serveBlob = async function (ctx) {
|
|||
return;
|
||||
}
|
||||
|
||||
const size = buffer.length;
|
||||
|
||||
let mime = 'application/octet-stream';
|
||||
try {
|
||||
const ft = await FileType.fromBuffer(buffer);
|
||||
|
|
@ -477,7 +216,7 @@ const serveBlob = async function (ctx) {
|
|||
} catch {}
|
||||
|
||||
if (mime === 'application/octet-stream' && buffer.length > 10 && buffer[0] === 0x64) {
|
||||
const head = buffer.slice(0, 512).toString('ascii');
|
||||
const head = buffer.slice(0, 128).toString('ascii');
|
||||
if (head.includes('announce') || head.includes('8:announce') || head.includes('4:info')) mime = 'application/x-bittorrent';
|
||||
}
|
||||
|
||||
|
|
@ -490,22 +229,51 @@ const serveBlob = async function (ctx) {
|
|||
}
|
||||
}
|
||||
|
||||
if (mime === 'application/ogg') mime = oggMime(buffer);
|
||||
|
||||
const isSvg = mime === 'image/svg+xml';
|
||||
const qName = ctx.query.name ? String(ctx.query.name).replace(/["\r\n\\]/g, '').trim() : '';
|
||||
const safeRaw = String(raw).replace(/["\r\n\\]/g, '');
|
||||
const wantsDownload = String(ctx.query.download || '') === '1';
|
||||
const extByMime = { 'image/jpeg': 'jpg', 'image/png': 'png', 'image/gif': 'gif', 'image/webp': 'webp', 'image/svg+xml': 'svg', 'video/mp4': 'mp4', 'video/webm': 'webm', 'audio/mpeg': 'mp3', 'audio/ogg': 'ogg', 'application/pdf': 'pdf', 'application/x-bittorrent': 'torrent' };
|
||||
const ext = extByMime[mime] || String(mime.split('/')[1] || 'bin').replace(/[^a-z0-9]/gi, '').slice(0, 8) || 'bin';
|
||||
const filename = qName || (mime === 'application/x-bittorrent' ? 'download.torrent' : (wantsDownload ? `oasis-${safeRaw.replace(/^&/, '').slice(0, 8)}.${ext}` : safeRaw));
|
||||
const disposition = isSvg || wantsDownload ? 'attachment' : 'inline';
|
||||
const filename = qName || (mime === 'application/x-bittorrent' ? 'download.torrent' : safeRaw);
|
||||
const disposition = isSvg ? 'attachment' : 'inline';
|
||||
ctx.type = mime;
|
||||
ctx.set('Content-Disposition', contentDisposition(disposition, filename));
|
||||
ctx.set('Content-Disposition', `${disposition}; filename="${filename}"`);
|
||||
ctx.set('Cache-Control', 'public, max-age=31536000, immutable');
|
||||
|
||||
sendBlobBuffer(ctx, buffer);
|
||||
const range = ctx.headers.range;
|
||||
|
||||
if (range) {
|
||||
const match = /^bytes=(\d*)-(\d*)$/.exec(range);
|
||||
if (!match) {
|
||||
ctx.status = 416;
|
||||
ctx.set('Content-Range', `bytes */${size}`);
|
||||
return;
|
||||
}
|
||||
|
||||
let start = match[1] ? parseInt(match[1], 10) : 0;
|
||||
let end = match[2] ? parseInt(match[2], 10) : size - 1;
|
||||
|
||||
if (Number.isNaN(start) || start < 0) start = 0;
|
||||
if (Number.isNaN(end) || end >= size) end = size - 1;
|
||||
|
||||
if (start > end || start >= size) {
|
||||
ctx.status = 416;
|
||||
ctx.set('Content-Range', `bytes */${size}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const chunk = buffer.slice(start, end + 1);
|
||||
|
||||
ctx.status = 206;
|
||||
ctx.set('Content-Range', `bytes ${start}-${end}/${size}`);
|
||||
ctx.set('Accept-Ranges', 'bytes');
|
||||
ctx.set('Content-Length', String(chunk.length));
|
||||
ctx.body = chunk;
|
||||
} else {
|
||||
ctx.status = 200;
|
||||
ctx.set('Accept-Ranges', 'bytes');
|
||||
ctx.set('Content-Length', String(size));
|
||||
ctx.body = buffer;
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { handleBlobUpload, handleBlobUploads, serveBlob, sendBlobBuffer, contentDisposition, oggMime, FileTooLargeError, stripImageMetadata };
|
||||
module.exports = { handleBlobUpload, serveBlob, FileTooLargeError };
|
||||
|
||||
|
|
|
|||
|
|
@ -49,79 +49,4 @@ const dedupeByPreferring = (items, keyFn, scoreFn) => {
|
|||
return order.map(o => (o.solo !== undefined ? o.solo : o.slot.v));
|
||||
};
|
||||
|
||||
const versionGroups = (entries) => {
|
||||
const byKey = new Map();
|
||||
for (const e of (entries || [])) if (e && e.key) byKey.set(e.key, e);
|
||||
const memo = new Map();
|
||||
const rootOf = (key) => {
|
||||
if (memo.has(key)) return memo.get(key);
|
||||
const path = [];
|
||||
const seen = new Set([key]);
|
||||
let cur = key;
|
||||
let root = null;
|
||||
while (root === null) {
|
||||
if (memo.has(cur)) { root = memo.get(cur); break; }
|
||||
const e = byKey.get(cur);
|
||||
const p = e && e.replaces;
|
||||
if (!p || !byKey.has(p) || seen.has(p)) { root = cur; break; }
|
||||
path.push(cur);
|
||||
seen.add(p);
|
||||
cur = p;
|
||||
}
|
||||
memo.set(cur, root);
|
||||
for (const k of path) memo.set(k, root);
|
||||
return root;
|
||||
};
|
||||
const isOrphan = (key) => {
|
||||
const e = byKey.get(key);
|
||||
return !!(e && e.replaces && !byKey.has(e.replaces));
|
||||
};
|
||||
const isForeignCopy = (key) => {
|
||||
const e = byKey.get(key);
|
||||
return !!(e && isOrphan(key) && typeof e.claimedAuthor === 'string' && e.claimedAuthor && e.claimedAuthor !== e.author);
|
||||
};
|
||||
const groups = new Map();
|
||||
for (const k of byKey.keys()) {
|
||||
const r = rootOf(k);
|
||||
if (!groups.has(r)) groups.set(r, []);
|
||||
groups.get(r).push(k);
|
||||
}
|
||||
const later = (a, b) => (a.seq || 0) > (b.seq || 0) || ((a.seq || 0) === (b.seq || 0) && (a.ts || 0) >= (b.ts || 0));
|
||||
for (const [r, keys] of Array.from(groups.entries())) {
|
||||
if (!isForeignCopy(r)) continue;
|
||||
const owner = byKey.get(r).claimedAuthor;
|
||||
let first = null;
|
||||
for (const k of keys) { const e = byKey.get(k); if (e.author === owner && (!first || later(byKey.get(first), e))) first = k; }
|
||||
if (!first) continue;
|
||||
groups.delete(r);
|
||||
groups.set(first, keys);
|
||||
for (const k of keys) memo.set(k, first);
|
||||
}
|
||||
const ownerTip = new Map();
|
||||
for (const [r, keys] of groups) {
|
||||
const re = byKey.get(r);
|
||||
for (const k of keys) {
|
||||
const e = byKey.get(k);
|
||||
if (!re || e.author !== re.author) continue;
|
||||
const prev = ownerTip.has(r) ? byKey.get(ownerTip.get(r)) : null;
|
||||
if (!prev || later(e, prev)) ownerTip.set(r, k);
|
||||
}
|
||||
}
|
||||
const isForeign = (key) => {
|
||||
const e = byKey.get(key);
|
||||
const re = byKey.get(rootOf(key));
|
||||
return !!(e && re && e.author !== re.author);
|
||||
};
|
||||
const roots = new Set();
|
||||
for (const r of groups.keys()) if (!isForeignCopy(r)) roots.add(r);
|
||||
const tipOf = (key) => { const r = rootOf(key); return ownerTip.get(r) || r; };
|
||||
return { rootOf, tipOf, roots, groups, isOrphan, isForeign, isForeignCopy };
|
||||
};
|
||||
|
||||
const versionEntry = (m) => {
|
||||
const v = (m && m.value) || {};
|
||||
const c = v.content || {};
|
||||
return { key: m && m.key, author: v.author, seq: v.sequence || 0, ts: v.timestamp || (m && m.timestamp) || 0, replaces: typeof c.replaces === 'string' ? c.replaces : null, claimedAuthor: typeof c.author === 'string' ? c.author : null };
|
||||
};
|
||||
|
||||
module.exports = { dedupeBy, mergeDuplicatesBy, dedupeByPreferring, norm, versionGroups, versionEntry };
|
||||
module.exports = { dedupeBy, mergeDuplicatesBy, dedupeByPreferring, norm };
|
||||
|
|
|
|||
|
|
@ -1,80 +0,0 @@
|
|||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { spawn } = require('child_process');
|
||||
const { getConfig } = require('../configs/config-manager.js');
|
||||
|
||||
const REPEAT_MS = 60000;
|
||||
const ICON = path.join(__dirname, '..', 'client', 'assets', 'images', 'snh-oasis.jpg');
|
||||
const recent = new Map();
|
||||
|
||||
const onPath = (bin) => (process.env.PATH || '').split(path.delimiter).some(d => d && fs.existsSync(path.join(d, bin)));
|
||||
|
||||
const i18nNow = () => {
|
||||
const all = require('../client/assets/translations/i18n');
|
||||
let lang = 'en';
|
||||
try { lang = getConfig().language || 'en'; } catch (_) {}
|
||||
return { ...all.en, ...(all[lang] || {}) };
|
||||
};
|
||||
|
||||
const displayName = (id, name) => {
|
||||
const n = String(name || '').trim();
|
||||
if (!n || n === 'Redacted' || n === String(id || '').replace(/^@/, '').slice(0, 8)) return String(id || '');
|
||||
return n.startsWith('@') ? n : `@${n}`;
|
||||
};
|
||||
|
||||
const MOBILE_QUEUE = path.join(__dirname, '..', '..', '.notifications.json');
|
||||
const queueForAndroid = (keyword, body, line, key) => {
|
||||
try {
|
||||
let list = [];
|
||||
try { list = JSON.parse(fs.readFileSync(MOBILE_QUEUE, 'utf8')); } catch (_) { list = []; }
|
||||
if (!Array.isArray(list)) list = [];
|
||||
const t = i18nNow();
|
||||
const k = String(keyword || '');
|
||||
const route = k === t.phoneTitle ? '/phone' : k === t.inbox ? '/inbox' : k === t.emergenciesTitle ? '/emergencies' : '/';
|
||||
const entry = { id: Date.now() + '-' + Math.random().toString(36).slice(2, 8), keyword: k || 'Oasis', body, line, route, key: key ? String(key) : null, ts: Date.now() };
|
||||
if (entry.key && entry.key.indexOf('call:') === 0) {
|
||||
const ringing = String(t.phoneIsCalling || '');
|
||||
entry.caller = ringing && body.endsWith(ringing) ? body.slice(0, body.length - ringing.length).trim() : body;
|
||||
entry.accept = String(t.phoneAnswer || '');
|
||||
entry.reject = String(t.phoneReject || '');
|
||||
}
|
||||
list.push(entry);
|
||||
while (list.length > 20) list.shift();
|
||||
fs.writeFileSync(MOBILE_QUEUE, JSON.stringify(list));
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const notify = (keyword, text, key = null, opts = {}) => {
|
||||
const onMobile = process.env.OASIS_MOBILE === '1';
|
||||
if (!onMobile && !onPath('notify-send')) return false;
|
||||
const body = String(text || '').replace(/\s+/g, ' ').trim().replace(/[.。!…]+$/, '').slice(0, 200);
|
||||
const line = `${String(keyword || 'Oasis').toUpperCase()}: ${body}...`;
|
||||
const now = Date.now();
|
||||
const dedupe = key || line;
|
||||
for (const [k, t] of recent) if (now - t > REPEAT_MS) recent.delete(k);
|
||||
if (recent.has(dedupe)) return false;
|
||||
recent.set(dedupe, now);
|
||||
if (onMobile) return queueForAndroid(keyword, body, line, key);
|
||||
const actions = Array.isArray(opts.actions) ? opts.actions.filter(x => x && x.key && x.label) : [];
|
||||
const extra = [
|
||||
...(opts.timeoutMs > 0 ? ['-t', String(Math.round(opts.timeoutMs))] : []),
|
||||
...actions.flatMap(x => ['-A', `${x.key}=${x.label}`])
|
||||
];
|
||||
try {
|
||||
const p = spawn('notify-send', ['-a', 'Oasis', ...(fs.existsSync(ICON) ? ['-i', ICON] : []), ...extra, line], { stdio: ['ignore', actions.length ? 'pipe' : 'ignore', 'ignore'] });
|
||||
p.on('error', () => {});
|
||||
if (actions.length && typeof opts.onAction === 'function') {
|
||||
let out = '';
|
||||
p.stdout.on('data', (d) => { out += d; });
|
||||
p.on('close', () => { const chosen = out.trim(); if (actions.some(x => x.key === chosen)) opts.onAction(chosen); });
|
||||
}
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { notify, i18nNow, displayName };
|
||||
|
|
@ -1,74 +0,0 @@
|
|||
const crypto = require('crypto');
|
||||
|
||||
const ALG = 'aes-256-gcm';
|
||||
const IV_LEN = 12;
|
||||
const TAG_LEN = 16;
|
||||
const KEY_LEN = 32;
|
||||
const DEFAULT_CHUNK_SIZE = 5 * 1024 * 1024;
|
||||
|
||||
const generateFileKey = () => crypto.randomBytes(KEY_LEN);
|
||||
|
||||
const keyToHex = (key) => Buffer.isBuffer(key) ? key.toString('hex') : String(key);
|
||||
|
||||
const keyFromHex = (hex) => {
|
||||
const buf = Buffer.from(String(hex), 'hex');
|
||||
if (buf.length !== KEY_LEN) throw new Error('Invalid file key');
|
||||
return buf;
|
||||
};
|
||||
|
||||
const encryptChunk = (plaintext, key) => {
|
||||
const iv = crypto.randomBytes(IV_LEN);
|
||||
const cipher = crypto.createCipheriv(ALG, key, iv);
|
||||
const ct = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
return Buffer.concat([iv, tag, ct]);
|
||||
};
|
||||
|
||||
const decryptChunk = (payload, key) => {
|
||||
if (!Buffer.isBuffer(payload) || payload.length < IV_LEN + TAG_LEN) throw new Error('Corrupt chunk');
|
||||
const iv = payload.subarray(0, IV_LEN);
|
||||
const tag = payload.subarray(IV_LEN, IV_LEN + TAG_LEN);
|
||||
const ct = payload.subarray(IV_LEN + TAG_LEN);
|
||||
const decipher = crypto.createDecipheriv(ALG, key, iv);
|
||||
decipher.setAuthTag(tag);
|
||||
return Buffer.concat([decipher.update(ct), decipher.final()]);
|
||||
};
|
||||
|
||||
const sha256Hex = (buffer) => crypto.createHash('sha256').update(buffer).digest('hex');
|
||||
|
||||
const splitBuffer = (buffer, chunkSize = DEFAULT_CHUNK_SIZE) => {
|
||||
const size = Math.max(1, Number(chunkSize) || DEFAULT_CHUNK_SIZE);
|
||||
const out = [];
|
||||
for (let off = 0; off < buffer.length; off += size) {
|
||||
out.push(buffer.subarray(off, Math.min(off + size, buffer.length)));
|
||||
}
|
||||
if (!out.length) out.push(Buffer.alloc(0));
|
||||
return out;
|
||||
};
|
||||
|
||||
const buildManifest = ({ filename, mime, size, chunkSize, chunkHashes, plainSha256 }) => ({
|
||||
v: 1,
|
||||
alg: ALG,
|
||||
filename: String(filename || 'file'),
|
||||
mime: String(mime || 'application/octet-stream'),
|
||||
size: Number(size) || 0,
|
||||
chunkSize: Number(chunkSize) || DEFAULT_CHUNK_SIZE,
|
||||
chunks: Array.isArray(chunkHashes) ? chunkHashes.slice() : [],
|
||||
plainSha256: plainSha256 || null
|
||||
});
|
||||
|
||||
const encryptManifest = (manifest, key) => encryptChunk(Buffer.from(JSON.stringify(manifest), 'utf8'), key);
|
||||
|
||||
const decryptManifest = (payload, key) => {
|
||||
const json = decryptChunk(payload, key).toString('utf8');
|
||||
const m = JSON.parse(json);
|
||||
if (!m || m.v !== 1 || !Array.isArray(m.chunks)) throw new Error('Invalid manifest');
|
||||
return m;
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
ALG, IV_LEN, TAG_LEN, KEY_LEN, DEFAULT_CHUNK_SIZE,
|
||||
generateFileKey, keyToHex, keyFromHex,
|
||||
encryptChunk, decryptChunk, sha256Hex, splitBuffer,
|
||||
buildManifest, encryptManifest, decryptManifest
|
||||
};
|
||||
|
|
@ -1,152 +0,0 @@
|
|||
"use strict";
|
||||
// Router propio de la rama movil.
|
||||
//
|
||||
// Va en la cadena justo delante de las rutas del dev (backend.js lo monta con una
|
||||
// sola linea). Lo que no atiende pasa al del dev con next(). Aqui viven:
|
||||
// - mobile_layer.js: lanzador, filtro y avatar de la barra de arriba
|
||||
// - wrapUpstream: mira lo que contesta el dev (seccion apagada, guardar modulos)
|
||||
// - las rutas propias: tema y Modulos en el movil, barra de abajo, identidades
|
||||
//
|
||||
// backend.js es del dev y cambia en 34 de cada 39 versiones: nada de esto va alli.
|
||||
// Ver GUIA_DE_DESARROLLO.md.
|
||||
|
||||
const koaRouter = require("../server/node_modules/@koa/router");
|
||||
const { koaBody } = require("../server/node_modules/koa-body");
|
||||
const compose = require("../server/node_modules/koa-compose");
|
||||
const { isLocalPath } = require("./request_guards");
|
||||
const { ALL_MODULES } = require("../models/workflows_model");
|
||||
const { mobileModulesView, STEMS } = require("../views/mobile_modules_view");
|
||||
require("../models/fork_workflows").register();
|
||||
|
||||
const isMobile = () => process.env.OASIS_MOBILE === '1';
|
||||
|
||||
// Seccion apagada: upstream responde con una redireccion a /modules sin decir de
|
||||
// donde venia. Este envoltorio la ve pasar y anota que modulo era, para que la
|
||||
// pantalla de Modulos del movil lo diga y ofrezca activarlo. La app es de una
|
||||
// persona, asi que basta con recordar el ultimo unos segundos.
|
||||
const SEGMENT_MOD = { tribe: 'tribes', vote: 'votes', document: 'docs', documents: 'docs', blog: 'blogs', event: 'events',
|
||||
task: 'tasks', calendar: 'calendars', image: 'images', video: 'videos', audio: 'audios', bookmark: 'bookmarks',
|
||||
file: 'files', poll: 'polls', report: 'reports', project: 'projects', shop: 'shops', job: 'jobs', campaign: 'campaigns',
|
||||
podcast: 'podcasts', room: 'rooms', map: 'maps', transfer: 'transfers', pad: 'pads', chat: 'chats', torrent: 'torrents' };
|
||||
const modOfPath = (p) => {
|
||||
const seg = String(p || '').split('/')[1] || '';
|
||||
const mod = STEMS[seg] ? seg : SEGMENT_MOD[seg];
|
||||
return mod && ALL_MODULES.includes(mod) ? mod : null;
|
||||
};
|
||||
let lastBlocked = null;
|
||||
const takeBlocked = () => {
|
||||
const b = lastBlocked && Date.now() - lastBlocked.at < 15000 ? lastBlocked : null;
|
||||
lastBlocked = null;
|
||||
return b;
|
||||
};
|
||||
const wrapUpstream = async (ctx, next) => {
|
||||
await next();
|
||||
if (!isMobile() || ctx.status < 300 || ctx.status >= 400) return;
|
||||
const to = String(ctx.response.get('Location') || '');
|
||||
// guardar en Modulos: volver con el aviso de guardado
|
||||
if (ctx.method === 'POST' && ctx.path === '/save-modules') { ctx.redirect('/modules?saved=1'); return; }
|
||||
if (ctx.method === 'GET' && ctx.path !== '/modules' && /^(https?:\/\/[^/]+)?\/modules$/.test(to)) {
|
||||
const mod = modOfPath(ctx.path);
|
||||
if (mod) lastBlocked = { mod, path: ctx.path, at: Date.now() };
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = ({ checkMod, getViewerId, getConfig, saveConfig, isLoopbackRequest, sendErrorPage, about }) => {
|
||||
const mobileLayer = require('./mobile_layer')({ getViewerId, about });
|
||||
|
||||
const forkRouter = new koaRouter();
|
||||
forkRouter
|
||||
// tema en el movil: solo los skins de movil (uno de escritorio deja la app sin
|
||||
// maqueta). Hace lo mismo que la ruta del dev, con esa comprobacion delante
|
||||
.post('/settings/theme', koaBody(), async (ctx, next) => {
|
||||
if (!isMobile()) return next();
|
||||
const theme = String((ctx.request.body || {}).theme || '').trim();
|
||||
const { MOBILE_THEMES } = require('../views/main_views');
|
||||
if (!MOBILE_THEMES.some(([value]) => value === theme)) { ctx.redirect('/settings#theme'); return; }
|
||||
const cfg = getConfig();
|
||||
cfg.themes.current = theme;
|
||||
saveConfig(cfg);
|
||||
ctx.cookies.set('theme', theme, { httpOnly: true, sameSite: 'strict', secure: ctx.secure });
|
||||
ctx.redirect('/settings#theme');
|
||||
})
|
||||
// Modulos en el movil: la vista propia (mobile_modules_view.js); en escritorio, la de upstream
|
||||
.get('/modules', async (ctx, next) => {
|
||||
if (!isMobile()) return next();
|
||||
ctx.body = mobileModulesView({ blocked: takeBlocked(), saved: ctx.query.saved === '1' });
|
||||
})
|
||||
// activar un solo modulo desde el aviso de Modulos y volver a donde se queria ir
|
||||
.post('/modules/enable', koaBody(), async (ctx) => {
|
||||
if (!isLoopbackRequest(ctx)) { ctx.status = 403; ctx.body = ''; return; }
|
||||
const mod = String((ctx.request.body || {}).mod || '').replace(/Mod$/, '');
|
||||
if (!ALL_MODULES.includes(mod)) { ctx.redirect('/modules'); return; }
|
||||
const cfg = getConfig();
|
||||
cfg.modules[`${mod}Mod`] = 'on';
|
||||
saveConfig(cfg);
|
||||
const back = String((ctx.request.body || {}).returnTo || '');
|
||||
ctx.redirect(isLocalPath(back) ? back : `/${mod}`);
|
||||
})
|
||||
.get("/settings/bottombar", async (ctx) => {
|
||||
ctx.body = require("../views/main_views").bottomBarPickerView();
|
||||
})
|
||||
.post("/settings/bottombar", koaBody(), async (ctx) => {
|
||||
const cfg = getConfig();
|
||||
const MAX = 4;
|
||||
const body = ctx.request.body || {};
|
||||
const action = String(body.action || '').trim();
|
||||
const key = String(body.key || '').trim();
|
||||
const catalog = require("../views/main_views").PINNABLE_MODULES || [];
|
||||
let pins = Array.isArray(cfg.bottomBarPins) ? cfg.bottomBarPins.slice() : [];
|
||||
if (action === 'add') {
|
||||
if (key && catalog.some(m => m.key === key) && !pins.includes(key) && pins.length < MAX) pins.push(key);
|
||||
} else if (action === 'remove') {
|
||||
pins = pins.filter(k => k !== key);
|
||||
} else if (action === 'clear') {
|
||||
pins = [];
|
||||
}
|
||||
cfg.bottomBarPins = pins.slice(0, MAX);
|
||||
saveConfig(cfg);
|
||||
ctx.redirect('/settings/bottombar');
|
||||
})
|
||||
.post("/settings/identity/use", koaBody(), async (ctx) => {
|
||||
// solo anota cual sera la del proximo arranque: cambiarla en caliente dejaria
|
||||
// media aplicacion hablando con el sbot anterior
|
||||
const accounts = require('./accounts.js');
|
||||
const name = String((ctx.request.body || {}).account || '').trim();
|
||||
if (!accounts.isValid(name) || !accounts.list().includes(name)) {
|
||||
sendErrorPage(ctx, require('../views/main_views').i18n.identitiesInvalidName, { status: 400 });
|
||||
return;
|
||||
}
|
||||
accounts.setActive(name);
|
||||
ctx.redirect('/settings');
|
||||
})
|
||||
.post("/settings/identity/create", koaBody(), async (ctx) => {
|
||||
const accounts = require('./accounts.js');
|
||||
const name = String((ctx.request.body || {}).account || '').trim();
|
||||
if (!accounts.isValid(name)) {
|
||||
sendErrorPage(ctx, require('../views/main_views').i18n.identitiesInvalidName, { status: 400 });
|
||||
return;
|
||||
}
|
||||
// no se genera el secret aqui: lo crea ssb-config al arrancar con esa cuenta,
|
||||
// que es quien sabe como hacerlo. Basta con anotarla y reiniciar.
|
||||
accounts.setActive(name);
|
||||
ctx.redirect('/settings');
|
||||
})
|
||||
.post("/settings/identity/restart", koaBody(), async (ctx) => {
|
||||
// Solo en movil: alli el backend vive en un servicio en primer plano que
|
||||
// sobrevive a cerrar la app, asi que la unica forma de arrancar con otra
|
||||
// identidad es terminar el proceso. nodejs-mobile no permite volver a
|
||||
// levantar el runtime dentro del mismo proceso: hay que salir y que la app
|
||||
// lo relance al abrirse.
|
||||
if (process.env.OASIS_MOBILE !== '1') { ctx.status = 404; ctx.body = ''; return; }
|
||||
ctx.body = require("../views/main_views").template(
|
||||
require('../views/main_views').i18n.identitiesTitle,
|
||||
require("../server/node_modules/hyperaxe").section(
|
||||
require("../server/node_modules/hyperaxe").p(require('../views/main_views').i18n.identitiesRestarting)
|
||||
)
|
||||
);
|
||||
// se responde primero: si se sale antes, el navegador se queda sin pagina
|
||||
setTimeout(() => process.exit(0), 500);
|
||||
});
|
||||
|
||||
return compose([mobileLayer, wrapUpstream, forkRouter.routes()]);
|
||||
};
|
||||
206
src/backend/logsPdf.js
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const LOGO_PATH = path.join(__dirname, '..', 'client', 'assets', 'images', 'snh-oasis.jpg');
|
||||
|
||||
const escapePdf = s => String(s || '').replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)');
|
||||
|
||||
const linkPattern = /(?:https?:\/\/[^\s]+|www\.[^\s]+|@[A-Za-z0-9+/=.\-]+\.ed25519|[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,})/g;
|
||||
|
||||
const splitSegments = (line) => {
|
||||
const segs = [];
|
||||
let last = 0;
|
||||
const re = new RegExp(linkPattern.source, 'g');
|
||||
let m;
|
||||
while ((m = re.exec(line)) !== null) {
|
||||
if (m.index > last) segs.push({ t: line.slice(last, m.index), l: false });
|
||||
segs.push({ t: m[0], l: true });
|
||||
last = m.index + m[0].length;
|
||||
}
|
||||
if (last < line.length) segs.push({ t: line.slice(last), l: false });
|
||||
return segs;
|
||||
};
|
||||
|
||||
const wrap = (txt, max = 82) => {
|
||||
const out = [];
|
||||
for (const raw of String(txt || '').split('\n')) {
|
||||
let line = raw;
|
||||
while (line.length > max) {
|
||||
let cut = line.lastIndexOf(' ', max);
|
||||
if (cut <= 0) cut = max;
|
||||
out.push(line.slice(0, cut));
|
||||
line = line.slice(cut).replace(/^\s+/, '');
|
||||
}
|
||||
out.push(line);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
const readJpegDims = (buf) => {
|
||||
let i = 2;
|
||||
while (i < buf.length) {
|
||||
if (buf[i] !== 0xFF) return null;
|
||||
const marker = buf[i + 1];
|
||||
if (marker === 0xD8 || marker === 0xD9) { i += 2; continue; }
|
||||
const len = buf.readUInt16BE(i + 2);
|
||||
if (marker >= 0xC0 && marker <= 0xCF && marker !== 0xC4 && marker !== 0xC8 && marker !== 0xCC) {
|
||||
const h = buf.readUInt16BE(i + 5);
|
||||
const w = buf.readUInt16BE(i + 7);
|
||||
const c = buf[i + 9];
|
||||
return { w, h, c };
|
||||
}
|
||||
i += 2 + len;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
function buildLogsPdf(entries, oasisId, opts = {}) {
|
||||
const pageW = 612;
|
||||
const pageH = 792;
|
||||
const marginX = 50;
|
||||
const headerH = 90;
|
||||
const footerH = 40;
|
||||
const bodyTop = pageH - headerH - 22;
|
||||
const bodyBottom = footerH + 10;
|
||||
const lineH = 12;
|
||||
const maxBodyLines = Math.floor((bodyTop - bodyBottom) / lineH);
|
||||
|
||||
let logoBuf = null;
|
||||
let logoDims = null;
|
||||
try {
|
||||
logoBuf = fs.readFileSync(LOGO_PATH);
|
||||
logoDims = readJpegDims(logoBuf);
|
||||
} catch (_) {}
|
||||
|
||||
const allLines = [];
|
||||
for (const e of entries) {
|
||||
const ts = new Date(e.ts);
|
||||
const when = ts.toISOString().replace('T', ' ').slice(0, 19);
|
||||
allLines.push({ kind: 'header', text: `[${when}]:` });
|
||||
allLines.push({ kind: 'blank', text: '' });
|
||||
for (const l of wrap(e.text, 82)) allLines.push({ kind: 'text', text: l });
|
||||
allLines.push({ kind: 'blank', text: '' });
|
||||
}
|
||||
if (!allLines.length) allLines.push({ kind: 'text', text: '(no entries)' });
|
||||
|
||||
const pages = [];
|
||||
for (let i = 0; i < allLines.length; i += maxBodyLines) {
|
||||
pages.push(allLines.slice(i, i + maxBodyLines));
|
||||
}
|
||||
if (!pages.length) pages.push([{ kind: 'text', text: '(no entries)' }]);
|
||||
|
||||
const objects = [];
|
||||
const addObj = body => { objects.push(body); return objects.length; };
|
||||
|
||||
const catalogId = addObj(null);
|
||||
const pagesId = addObj(null);
|
||||
const fontId = addObj('<< /Type /Font /Subtype /Type1 /BaseFont /Courier >>');
|
||||
const fontBoldId = addObj('<< /Type /Font /Subtype /Type1 /BaseFont /Courier-Bold >>');
|
||||
|
||||
let logoXObjId = null;
|
||||
if (logoBuf && logoDims) {
|
||||
const colorSpace = logoDims.c === 1 ? '/DeviceGray' : '/DeviceRGB';
|
||||
const dict = `<< /Type /XObject /Subtype /Image /Width ${logoDims.w} /Height ${logoDims.h} /ColorSpace ${colorSpace} /BitsPerComponent 8 /Filter /DCTDecode /Length ${logoBuf.length} >>`;
|
||||
logoXObjId = addObj({ dict, stream: logoBuf });
|
||||
}
|
||||
|
||||
const exportDate = new Date().toISOString().replace('T', ' ').slice(0, 19) + ' UTC';
|
||||
const footerLeft = `Generated: ${exportDate}`;
|
||||
|
||||
const pageIds = [];
|
||||
const contentIds = [];
|
||||
|
||||
pages.forEach((pg, pgIdx) => {
|
||||
const parts = [];
|
||||
|
||||
if (logoXObjId) {
|
||||
const logoH = 60;
|
||||
const logoW = Math.round((logoDims.w / logoDims.h) * logoH);
|
||||
const logoX = marginX;
|
||||
const logoY = pageH - headerH + 15;
|
||||
parts.push(`q\n${logoW} 0 0 ${logoH} ${logoX} ${logoY} cm\n/Logo Do\nQ`);
|
||||
}
|
||||
|
||||
const titleX = (logoXObjId ? marginX + 80 : marginX);
|
||||
const titleY = pageH - 45;
|
||||
parts.push(`BT\n/F2 16 Tf\n${titleX} ${titleY} Td\n(${escapePdf('OASIS - Experience logs')}) Tj\nET`);
|
||||
const inhabitantPrefix = 'Inhabitant: ';
|
||||
const inhabitantPrefixW = inhabitantPrefix.length * 5.4;
|
||||
parts.push(`BT\n/F1 9 Tf\n${titleX} ${titleY - 16} Td\n(${escapePdf(inhabitantPrefix)}) Tj\nET`);
|
||||
parts.push(`BT\n/F2 9 Tf\n${titleX + inhabitantPrefixW} ${titleY - 16} Td\n(${escapePdf(String(oasisId || ''))}) Tj\nET`);
|
||||
|
||||
parts.push(`q\n0.6 0.6 0.6 RG\n0.5 w\n${marginX} ${pageH - headerH} m\n${pageW - marginX} ${pageH - headerH} l\nS\nQ`);
|
||||
|
||||
let y = bodyTop;
|
||||
const charW = 6;
|
||||
for (const ln of pg) {
|
||||
if (ln.kind === 'header') {
|
||||
parts.push(`BT\n/F2 10 Tf\n1 0.647 0 rg\n${marginX} ${y} Td\n(${escapePdf(ln.text)}) Tj\nET`);
|
||||
} else if (ln.text) {
|
||||
const segs = splitSegments(ln.text);
|
||||
let x = marginX;
|
||||
for (const s of segs) {
|
||||
if (!s.t) continue;
|
||||
const color = s.l ? '0 0 1 rg' : '0 0 0 rg';
|
||||
parts.push(`BT\n/F1 10 Tf\n${color}\n${x} ${y} Td\n(${escapePdf(s.t)}) Tj\nET`);
|
||||
x += s.t.length * charW;
|
||||
}
|
||||
}
|
||||
y -= lineH;
|
||||
}
|
||||
|
||||
parts.push(`q\n0.6 0.6 0.6 RG\n0.5 w\n${marginX} ${footerH + 5} m\n${pageW - marginX} ${footerH + 5} l\nS\nQ`);
|
||||
parts.push(`BT\n/F1 8 Tf\n${marginX} ${footerH - 10} Td\n(${escapePdf(footerLeft)}) Tj\nET`);
|
||||
const pageLabel = `Page ${pgIdx + 1} of ${pages.length}`;
|
||||
const pageLabelW = pageLabel.length * 4.8;
|
||||
parts.push(`BT\n/F1 8 Tf\n${pageW - marginX - pageLabelW} ${footerH - 10} Td\n(${escapePdf(pageLabel)}) Tj\nET`);
|
||||
|
||||
const content = parts.join('\n');
|
||||
const stream = `<< /Length ${Buffer.byteLength(content)} >>\nstream\n${content}\nendstream`;
|
||||
const cid = addObj(stream);
|
||||
contentIds.push(cid);
|
||||
const pid = addObj(null);
|
||||
pageIds.push(pid);
|
||||
});
|
||||
|
||||
for (let i = 0; i < pageIds.length; i++) {
|
||||
const resources = logoXObjId
|
||||
? `<< /Font << /F1 ${fontId} 0 R /F2 ${fontBoldId} 0 R >> /XObject << /Logo ${logoXObjId} 0 R >> >>`
|
||||
: `<< /Font << /F1 ${fontId} 0 R /F2 ${fontBoldId} 0 R >> >>`;
|
||||
objects[pageIds[i] - 1] = `<< /Type /Page /Parent ${pagesId} 0 R /MediaBox [0 0 ${pageW} ${pageH}] /Contents ${contentIds[i]} 0 R /Resources ${resources} >>`;
|
||||
}
|
||||
|
||||
objects[catalogId - 1] = `<< /Type /Catalog /Pages ${pagesId} 0 R >>`;
|
||||
objects[pagesId - 1] = `<< /Type /Pages /Kids [${pageIds.map(id => `${id} 0 R`).join(' ')}] /Count ${pageIds.length} >>`;
|
||||
|
||||
const chunks = [];
|
||||
const offsets = [0];
|
||||
let byteLen = 0;
|
||||
const push = (buf) => { chunks.push(buf); byteLen += buf.length; };
|
||||
|
||||
push(Buffer.from('%PDF-1.4\n%\xE2\xE3\xCF\xD3\n', 'binary'));
|
||||
|
||||
for (let i = 0; i < objects.length; i++) {
|
||||
offsets.push(byteLen);
|
||||
const obj = objects[i];
|
||||
if (obj && typeof obj === 'object' && obj.dict && obj.stream) {
|
||||
push(Buffer.from(`${i + 1} 0 obj\n${obj.dict}\nstream\n`, 'binary'));
|
||||
push(obj.stream);
|
||||
push(Buffer.from('\nendstream\nendobj\n', 'binary'));
|
||||
} else {
|
||||
push(Buffer.from(`${i + 1} 0 obj\n${obj}\nendobj\n`, 'binary'));
|
||||
}
|
||||
}
|
||||
|
||||
const xrefStart = byteLen;
|
||||
let xref = `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`;
|
||||
for (let i = 1; i <= objects.length; i++) {
|
||||
xref += `${String(offsets[i]).padStart(10, '0')} 00000 n \n`;
|
||||
}
|
||||
xref += `trailer\n<< /Size ${objects.length + 1} /Root ${catalogId} 0 R >>\nstartxref\n${xrefStart}\n%%EOF`;
|
||||
push(Buffer.from(xref, 'binary'));
|
||||
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
|
||||
module.exports = { buildLogsPdf };
|
||||
|
|
@ -1,195 +0,0 @@
|
|||
const crypto = require('crypto');
|
||||
|
||||
const CHUNK_TYPE = 'textChunk';
|
||||
const TEXT_CAP = 60000;
|
||||
const CONTENT_BYTES = { plain: 6500, boxed: 4500, sealed: 3300 };
|
||||
const MIN_HEAD_BYTES = 200;
|
||||
const CACHE_MAX = 400;
|
||||
const KEY_RE = /^%[A-Za-z0-9+/]{43}=?\.sha256$/;
|
||||
const KEY_SAMPLE = '%' + 'A'.repeat(44) + '.sha256';
|
||||
|
||||
const byteLength = (s) => Buffer.byteLength(String(s == null ? '' : s), 'utf8');
|
||||
const contentBytes = (content) => { try { return byteLength(JSON.stringify(content)); } catch (_) { return Infinity; } };
|
||||
const isChunkKey = (k) => typeof k === 'string' && KEY_RE.test(k);
|
||||
const tooLong = (text) => String(text == null ? '' : text).length > TEXT_CAP;
|
||||
|
||||
const cutAt = (text, maxBytes) => {
|
||||
let i = 0, bytes = 0;
|
||||
while (i < text.length) {
|
||||
const cp = text.codePointAt(i);
|
||||
const len = cp < 0x80 ? 1 : cp < 0x800 ? 2 : cp < 0x10000 ? 3 : 4;
|
||||
if (bytes + len > maxBytes) break;
|
||||
bytes += len;
|
||||
i += cp >= 0x10000 ? 2 : 1;
|
||||
}
|
||||
if (i >= text.length) return text.length;
|
||||
const min = Math.floor(i / 2);
|
||||
const para = text.lastIndexOf('\n\n', i);
|
||||
if (para > min) return para + 2;
|
||||
const line = text.lastIndexOf('\n', i);
|
||||
if (line > min) return line + 1;
|
||||
const space = text.lastIndexOf(' ', i);
|
||||
if (space > min) return space + 1;
|
||||
return Math.max(i, 1);
|
||||
};
|
||||
|
||||
const splitText = (text, maxBytes, firstMax = maxBytes) => {
|
||||
const s = String(text == null ? '' : text);
|
||||
const out = [];
|
||||
let rest = s;
|
||||
let limit = Math.max(1, firstMax);
|
||||
while (rest.length) {
|
||||
const cut = cutAt(rest, limit);
|
||||
out.push(rest.slice(0, cut));
|
||||
rest = rest.slice(cut);
|
||||
limit = Math.max(1, maxBytes);
|
||||
}
|
||||
return out.length ? out : [''];
|
||||
};
|
||||
|
||||
const chunkTemplate = (extra) => ({ type: CHUNK_TYPE, text: '', index: 999, total: 999, group: 'g'.repeat(16), ...extra });
|
||||
const chunkBudget = (extra, maxBytes) => maxBytes - contentBytes(chunkTemplate(extra));
|
||||
const headBudget = (content, field, maxBytes, count) => maxBytes - contentBytes({ ...content, [field]: '', chunks: new Array(count).fill(KEY_SAMPLE) });
|
||||
|
||||
const needsChunking = (content, maxBytes = CONTENT_BYTES.plain) => contentBytes(content) > maxBytes;
|
||||
|
||||
const planPieces = (content, field, { maxBytes = CONTENT_BYTES.plain, extra = {} } = {}) => {
|
||||
const text = String(content[field] == null ? '' : content[field]);
|
||||
if (tooLong(text)) throw new Error('Text too long');
|
||||
if (!needsChunking(content, maxBytes)) return null;
|
||||
const perChunk = chunkBudget(extra, maxBytes);
|
||||
if (perChunk < MIN_HEAD_BYTES) throw new Error('Text does not fit');
|
||||
let count = 0;
|
||||
let pieces = null;
|
||||
for (let guard = 0; guard < 12; guard++) {
|
||||
const head = headBudget(content, field, maxBytes, count);
|
||||
if (head < MIN_HEAD_BYTES) throw new Error('Text does not fit');
|
||||
pieces = splitText(text, perChunk, head);
|
||||
if (pieces.length - 1 <= count) return pieces;
|
||||
count = pieces.length - 1;
|
||||
}
|
||||
return pieces;
|
||||
};
|
||||
|
||||
const publishChunks = async (pieces, publish, { extra = {}, group } = {}) => {
|
||||
const g = group || crypto.randomBytes(8).toString('hex');
|
||||
const total = pieces.length;
|
||||
const keys = [];
|
||||
for (let i = 0; i < pieces.length; i++) {
|
||||
const r = await publish({ type: CHUNK_TYPE, text: pieces[i], index: i + 1, total, group: g, ...extra });
|
||||
const key = r && typeof r === 'object' ? r.key : r;
|
||||
if (!isChunkKey(key)) throw new Error('Chunk publish failed');
|
||||
keys.push(key);
|
||||
}
|
||||
return keys;
|
||||
};
|
||||
|
||||
const chunkContent = async (content, field, { maxBytes = CONTENT_BYTES.plain, extra = {}, publish } = {}) => {
|
||||
const base = { ...content };
|
||||
delete base.chunks;
|
||||
const pieces = planPieces(base, field, { maxBytes, extra });
|
||||
if (!pieces) return base;
|
||||
const keys = await publishChunks(pieces.slice(1), publish, { extra });
|
||||
return { ...base, [field]: pieces[0], chunks: keys };
|
||||
};
|
||||
|
||||
const chunkOf = (author, c) => {
|
||||
if (!c || typeof c !== 'object' || typeof c.text !== 'string') return null;
|
||||
if (c.type !== CHUNK_TYPE && c.k !== CHUNK_TYPE) return null;
|
||||
return { author, text: c.text, index: Number(c.index) || 0, total: Number(c.total) || 0, group: String(c.group || '') };
|
||||
};
|
||||
|
||||
const joinText = (head, keys, lookup, author) => {
|
||||
let out = String(head == null ? '' : head);
|
||||
if (!Array.isArray(keys) || !keys.length) return out;
|
||||
let group = null;
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
const k = keys[i];
|
||||
if (!isChunkKey(k)) break;
|
||||
const ch = lookup(k);
|
||||
if (!ch || (author && ch.author !== author)) break;
|
||||
if (group === null) group = ch.group; else if (ch.group !== group) break;
|
||||
if (ch.index !== i + 1 || ch.total !== keys.length) break;
|
||||
out += ch.text;
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
const hasChunks = (content) => !!content && Array.isArray(content.chunks) && content.chunks.length > 0;
|
||||
|
||||
const resolveField = (content, field, author, lookup) => {
|
||||
if (!hasChunks(content)) return content;
|
||||
return { ...content, [field]: joinText(content[field], content.chunks, lookup, author) };
|
||||
};
|
||||
|
||||
const indexChunks = (messages, decode) => {
|
||||
const index = new Map();
|
||||
for (const m of messages || []) {
|
||||
const v = m && m.value;
|
||||
if (!v || !m.key) continue;
|
||||
const raw = v.content;
|
||||
const c = decode ? decode(raw, m) : raw;
|
||||
const ch = chunkOf(v.author, c);
|
||||
if (ch) index.set(m.key, ch);
|
||||
}
|
||||
return index;
|
||||
};
|
||||
|
||||
const lookupIn = (index) => (k) => index.get(k) || null;
|
||||
|
||||
const caches = new WeakMap();
|
||||
const cacheFor = (ssb) => {
|
||||
let c = caches.get(ssb);
|
||||
if (!c) { c = new Map(); caches.set(ssb, c); }
|
||||
return c;
|
||||
};
|
||||
const remember = (cache, key, value) => {
|
||||
if (cache.has(key)) cache.delete(key);
|
||||
cache.set(key, value);
|
||||
while (cache.size > CACHE_MAX) cache.delete(cache.keys().next().value);
|
||||
return value;
|
||||
};
|
||||
|
||||
const fetchChunk = async (ssb, key, unbox) => {
|
||||
const cache = cacheFor(ssb);
|
||||
if (cache.has(key)) { const v = cache.get(key); cache.delete(key); cache.set(key, v); return v; }
|
||||
const raw = await new Promise((resolve) => { try { ssb.get(key, (err, m) => resolve(err ? null : m)); } catch (_) { resolve(null); } });
|
||||
if (!raw) return null;
|
||||
let content = raw.content;
|
||||
if (typeof content === 'string' && unbox) { try { content = unbox(content); } catch (_) { content = null; } }
|
||||
const ch = chunkOf(raw.author, content);
|
||||
return ch ? remember(cache, key, ch) : null;
|
||||
};
|
||||
|
||||
const joinChunks = async (ssb, content, author, { field = 'text', unbox = null } = {}) => {
|
||||
if (!hasChunks(content)) return content;
|
||||
const found = new Map();
|
||||
for (const k of content.chunks) {
|
||||
if (!isChunkKey(k)) break;
|
||||
const ch = await fetchChunk(ssb, k, unbox);
|
||||
if (!ch) break;
|
||||
found.set(k, ch);
|
||||
}
|
||||
return resolveField(content, field, author, lookupIn(found));
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
CHUNK_TYPE,
|
||||
TEXT_CAP,
|
||||
CONTENT_BYTES,
|
||||
byteLength,
|
||||
contentBytes,
|
||||
tooLong,
|
||||
splitText,
|
||||
needsChunking,
|
||||
planPieces,
|
||||
publishChunks,
|
||||
chunkContent,
|
||||
chunkOf,
|
||||
joinText,
|
||||
hasChunks,
|
||||
resolveField,
|
||||
indexChunks,
|
||||
lookupIn,
|
||||
joinChunks
|
||||
};
|
||||
|
|
@ -1,43 +1,22 @@
|
|||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const storePath = () => require("../configs/state-manager").statePath("content_favorites.json");
|
||||
const FILE = path.join(__dirname, "../configs/media-favorites.json");
|
||||
|
||||
const DEFAULT = {
|
||||
emergencies: [],
|
||||
audios: [],
|
||||
blogs: [],
|
||||
bookmarks: [],
|
||||
calendars: [],
|
||||
campaigns: [],
|
||||
chats: [],
|
||||
documents: [],
|
||||
events: [],
|
||||
forum: [],
|
||||
housing: [],
|
||||
images: [],
|
||||
jobs: [],
|
||||
logistics: [],
|
||||
logs: [],
|
||||
mailing: [],
|
||||
maps: [],
|
||||
pads: [],
|
||||
rooms: [],
|
||||
podcasts: [],
|
||||
polls: [],
|
||||
projects: [],
|
||||
reports: [],
|
||||
school: [],
|
||||
shops: [],
|
||||
market: [],
|
||||
shopProducts: [],
|
||||
tasks: [],
|
||||
torrents: [],
|
||||
files: [],
|
||||
transfers: [],
|
||||
videos: [],
|
||||
votes: [],
|
||||
wiki: []
|
||||
videos: []
|
||||
};
|
||||
|
||||
const safeArr = (v) => (Array.isArray(v) ? v : []);
|
||||
|
|
@ -60,32 +39,32 @@ const normalize = (raw) => {
|
|||
|
||||
const ensureFile = async () => {
|
||||
try {
|
||||
await fs.promises.access(storePath());
|
||||
await fs.promises.access(FILE);
|
||||
} catch (e) {
|
||||
const dir = path.dirname(storePath());
|
||||
const dir = path.dirname(FILE);
|
||||
await fs.promises.mkdir(dir, { recursive: true });
|
||||
await fs.promises.writeFile(storePath(), JSON.stringify(DEFAULT, null, 2), "utf8");
|
||||
await fs.promises.writeFile(FILE, JSON.stringify(DEFAULT, null, 2), "utf8");
|
||||
}
|
||||
};
|
||||
|
||||
const readAll = async () => {
|
||||
await ensureFile();
|
||||
try {
|
||||
const txt = await fs.promises.readFile(storePath(), "utf8");
|
||||
const txt = await fs.promises.readFile(FILE, "utf8");
|
||||
return normalize(JSON.parse(txt || "{}"));
|
||||
} catch (e) {
|
||||
const fixed = normalize(DEFAULT);
|
||||
await fs.promises.writeFile(storePath(), JSON.stringify(fixed, null, 2), "utf8");
|
||||
await fs.promises.writeFile(FILE, JSON.stringify(fixed, null, 2), "utf8");
|
||||
return fixed;
|
||||
}
|
||||
};
|
||||
|
||||
const writeAll = async (data) => {
|
||||
const dir = path.dirname(storePath());
|
||||
const tmp = path.join(dir, `.content_favorites.${process.pid}.${Date.now()}.tmp`);
|
||||
const dir = path.dirname(FILE);
|
||||
const tmp = path.join(dir, `.media-favorites.${process.pid}.${Date.now()}.tmp`);
|
||||
const txt = JSON.stringify(normalize(data), null, 2);
|
||||
await fs.promises.writeFile(tmp, txt, "utf8");
|
||||
await fs.promises.rename(tmp, storePath());
|
||||
await fs.promises.rename(tmp, FILE);
|
||||
};
|
||||
|
||||
const assertKind = (kind) => {
|
||||
|
|
@ -109,15 +88,6 @@ exports.getFavoriteSet = async (kind) => {
|
|||
return new Set(safeArr(data[k]).map(String));
|
||||
};
|
||||
|
||||
exports.getFavoriteIndex = async () => {
|
||||
const data = await readAll();
|
||||
const index = new Map();
|
||||
for (const kind of Object.keys(DEFAULT)) {
|
||||
for (const id of safeArr(data[kind])) index.set(String(id), kind);
|
||||
}
|
||||
return index;
|
||||
};
|
||||
|
||||
exports.addFavorite = async (kind, id) =>
|
||||
withLock(async () => {
|
||||
const k = assertKind(kind);
|
||||
|
|
@ -153,4 +123,4 @@ exports.removeFromFavorites = async (...args) => {
|
|||
const id = idFromArgs(args);
|
||||
return exports.removeFavorite(kind, id);
|
||||
};
|
||||
exports.storePath = storePath;
|
||||
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
"use strict";
|
||||
// Lo que la interfaz de la rama necesita del backend, fuera de backend.js.
|
||||
//
|
||||
// Prepara lo que leen la barra de arriba y el lanzador de hive_views.js: el filtro
|
||||
// Personal/Social, que el lanzador no va en la portada y el avatar propio. Lo
|
||||
// monta el router propio (fork_routes.js) delante de las rutas del dev. Nada de
|
||||
// esto corre en escritorio. Ver GUIA_DE_DESARROLLO.md.
|
||||
//
|
||||
// Lo demas que el movil necesita del backend (modulos que no van, rutas que se
|
||||
// cortan, retoques del HTML, descargas de la copia de seguridad) lo hace el propio
|
||||
// dev en la version de movil de su backend.js, la que va dentro de su APK.
|
||||
|
||||
const NULL_IMG = '&' + '0'.repeat(43) + '=.sha256';
|
||||
const isMobile = () => process.env.OASIS_MOBILE === '1';
|
||||
const isPage = (p) => !/^\/(assets|image|blob|qr|c)(\/|$)/.test(p);
|
||||
|
||||
module.exports = ({ getViewerId, about }) => {
|
||||
let avatarAt = 0;
|
||||
const refreshAvatar = async () => {
|
||||
avatarAt = Date.now();
|
||||
try {
|
||||
const me = getViewerId();
|
||||
const img = me ? await about.image(me) : null;
|
||||
if (typeof img === 'string' && img[0] === '&' && img !== NULL_IMG) global.__OASIS_SELF_AVATAR__ = `/image/256/${encodeURIComponent(img)}`;
|
||||
else if (typeof img === 'string' && img[0] === '/') global.__OASIS_SELF_AVATAR__ = img;
|
||||
else global.__OASIS_SELF_AVATAR__ = null;
|
||||
} catch (_) {}
|
||||
};
|
||||
|
||||
return async (ctx, next) => {
|
||||
if (!isMobile() || !isPage(ctx.path)) return next();
|
||||
global.__OASIS_HIVE_FILTER__ = (ctx.path === '/activity' && (ctx.query.hive === 'personal' || ctx.query.hive === 'community')) ? ctx.query.hive : '';
|
||||
// el lanzador ya no va en la portada: la portada es la actividad y los modulos
|
||||
// estan en la hoja de la pestaña Menu (docs/ESTUDIO_NAVEGACION.md)
|
||||
global.__OASIS_SHOW_HIVE__ = false;
|
||||
if (!avatarAt) await refreshAvatar();
|
||||
else if (Date.now() - avatarAt > 60000) refreshAvatar();
|
||||
return next();
|
||||
};
|
||||
};
|
||||
|
|
@ -46,40 +46,5 @@ const all = [...positive, ...constructive, ...moderation];
|
|||
all.positive = positive;
|
||||
all.constructive = constructive;
|
||||
all.moderation = moderation;
|
||||
const karmaNeutral = ["adultOnly", "nsfw", "triggering", "duplicate", "offTopic", "needsSources"];
|
||||
const karmaModeration = [...moderation.filter(c => !karmaNeutral.includes(c)), "propaganda"];
|
||||
all.karma = {
|
||||
positive,
|
||||
neutral: karmaNeutral,
|
||||
constructive: constructive.filter(c => !karmaNeutral.includes(c) && !karmaModeration.includes(c)),
|
||||
moderation: karmaModeration
|
||||
};
|
||||
all.messageTypes = {
|
||||
bookmark: 'bookmarkOpinion',
|
||||
votes: 'votesOpinion',
|
||||
poll: 'pollOpinion',
|
||||
transfer: 'transferOpinion',
|
||||
feed: 'feedOpinion',
|
||||
post: 'blogOpinion',
|
||||
image: 'imageOpinion',
|
||||
audio: 'audioOpinion',
|
||||
video: 'videoOpinion',
|
||||
document: 'documentOpinion',
|
||||
torrent: 'torrentOpinion',
|
||||
file: 'fileOpinion',
|
||||
industry: 'industryOpinion',
|
||||
project: 'projectOpinion',
|
||||
report: 'reportOpinion',
|
||||
task: 'taskOpinion',
|
||||
event: 'eventOpinion',
|
||||
shopProduct: 'shopOpinion',
|
||||
housing: 'housingOpinion',
|
||||
market: 'marketOpinion',
|
||||
schoolCourse: 'schoolOpinion',
|
||||
podcast: 'podcastOpinion',
|
||||
podcastEpisode: 'podcastOpinion',
|
||||
campaign: 'campaignOpinion',
|
||||
logisticsRoute: 'logisticsOpinion'
|
||||
};
|
||||
|
||||
module.exports = all;
|
||||
|
|
|
|||
1485
src/backend/pdf.js
|
|
@ -1,51 +0,0 @@
|
|||
const fs = require('fs');
|
||||
|
||||
const ANNOUNCE_SEEN_FILE = 'oasis-political-seen';
|
||||
|
||||
const isMutual = (relationship) => !!(relationship && relationship.following && relationship.followsMe);
|
||||
|
||||
const isRecipientAllowed = ({ pmVisibility, viewerId, recipientId, relationship } = {}) => {
|
||||
if (recipientId && viewerId && recipientId === viewerId) return true;
|
||||
if (pmVisibility === 'mutuals') return isMutual(relationship);
|
||||
if (pmVisibility === 'following') return !!(relationship && relationship.following);
|
||||
return true;
|
||||
};
|
||||
|
||||
const announceSeenPath = () => {
|
||||
try { return require('../configs/state-manager').statePath(ANNOUNCE_SEEN_FILE); } catch (_) { return null; }
|
||||
};
|
||||
|
||||
const readAnnounceSeen = () => {
|
||||
const file = announceSeenPath();
|
||||
if (!file) return {};
|
||||
try {
|
||||
const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||
return parsed && typeof parsed === 'object' ? parsed : {};
|
||||
} catch (_) { return {}; }
|
||||
};
|
||||
|
||||
const writeAnnounceSeen = (state) => {
|
||||
const file = announceSeenPath();
|
||||
if (!file) return false;
|
||||
try { fs.writeFileSync(file, JSON.stringify(state || {})); return true; } catch (_) { return false; }
|
||||
};
|
||||
|
||||
const everAnnounced = (announced, subject) => {
|
||||
for (const entry of (announced || [])) {
|
||||
if (String(entry).startsWith(`${subject}|`)) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const decideAnnouncement = ({ subject, ref, announced = new Set(), seen = {} } = {}) => {
|
||||
if (!subject || !ref) return { send: false, remember: false };
|
||||
const key = String(ref);
|
||||
if (announced.has(`${subject}|${key}`)) return { send: false, remember: false };
|
||||
if (seen[subject] === key) return { send: false, remember: false };
|
||||
if (!everAnnounced(announced, subject) && seen[subject] === undefined) {
|
||||
return { send: false, remember: true };
|
||||
}
|
||||
return { send: true, remember: true };
|
||||
};
|
||||
|
||||
module.exports = { isRecipientAllowed, isMutual, announceSeenPath, readAnnounceSeen, writeAnnounceSeen, decideAnnouncement };
|
||||
|
|
@ -1,325 +0,0 @@
|
|||
const { a, img, video, audio, span, strong, em, u, s: strike, code, div, textarea } = require("../server/node_modules/hyperaxe");
|
||||
const i18nBase = require("../client/assets/translations/i18n");
|
||||
const { WIKILINK_RE, slugify, linkTarget } = require("../models/wiki_model");
|
||||
|
||||
let zoomSeq = 0;
|
||||
|
||||
function getI18n() {
|
||||
try {
|
||||
const { i18n } = require("../views/main_views");
|
||||
return i18n;
|
||||
} catch (_) {
|
||||
return i18nBase['en'] || {};
|
||||
}
|
||||
}
|
||||
|
||||
const ESCAPE_RE = /\\([\\*_~`#>\-\[\]!])/g;
|
||||
const CODE_BLOCK_RE = /```([\s\S]*?)```[ \t]*(?:\n(?:[ \t]*\n)*|$)/g;
|
||||
const RULE_RE = /^[ \t]*---[ \t]*(?:\n(?:[ \t]*\n)*|$)/gm;
|
||||
const HEADER_CLOSED_RE = /^[ \t]*(#{1,6})[ \t]*([^\n]{1,200}?)[ \t]*\1[ \t]*(?:\n(?:[ \t]*\n)*|$)/gm;
|
||||
const HEADER_SPACED_RE = /^[ \t]*(#{1,6})[ \t]+([^\n]{1,200}?)[ \t]*#*[ \t]*(?:\n(?:[ \t]*\n)*|$)/gm;
|
||||
const QUOTE_RE = /^[ \t]*>[ \t]?([^\n]*)(?:\n(?:[ \t]*\n)*|$)/gm;
|
||||
const BULLET_RE = /^([ \t]*)-[ \t]+([^\n]+)(?:\n(?:[ \t]*\n)*|$)/gm;
|
||||
const NUMBER_RE = /^([ \t]*)(\d{1,3})([.)])[ \t]+([^\n]+)(?:\n(?:[ \t]*\n)*|$)/gm;
|
||||
const BOLD_RE = /\*\*(?!\s)((?:[^*\n]|\*(?!\*))+?)(?<!\s)\*\*/g;
|
||||
const UNDERLINE_RE = /(?<![A-Za-z0-9])__(?!\s)((?:[^_\n]|_(?!_))+?)(?<!\s)__(?![A-Za-z0-9])/g;
|
||||
const STRIKE_RE = /~~(?!\s)([^~\n]+?)(?<!\s)~~/g;
|
||||
const ITALIC_RE = /\*(?!\s)((?:[^*\n]|\*\*)+?)(?<!\s)\*(?!\*)/g;
|
||||
const INLINE_CODE_RE = /`([^`\n]+)`/g;
|
||||
const BLOB_IMAGE_RE = /!\[([^\]\[\n]{0,160})\]\(\s*(&[^)\s]{1,120}\.sha256|\/blob\/sealed\/[^)\s]{1,600})\s*\)/g;
|
||||
const BLOB_VIDEO_RE = /\[video:([^\]\[\n]{0,160})\]\(\s*(&[^)\s]{1,120}\.sha256|\/blob\/sealed\/[^)\s]{1,600})\s*\)/g;
|
||||
const BLOB_AUDIO_RE = /\[audio:([^\]\[\n]{0,160})\]\(\s*(&[^)\s]{1,120}\.sha256|\/blob\/sealed\/[^)\s]{1,600})\s*\)/g;
|
||||
const BLOB_PDF_RE = /\[pdf:([^\]\[\n]{0,160})\]\(\s*(&[^)\s]{1,120}\.sha256|\/blob\/sealed\/[^)\s]{1,600})\s*\)/g;
|
||||
const BLOB_TORRENT_RE = /\[torrent:([^\]\[\n]{0,160})\]\(\s*(&[^)\s]{1,120}\.sha256|\/blob\/sealed\/[^)\s]{1,600})\s*\)/g;
|
||||
const BLOB_FILE_RE = /(?<!!)\[(?!(?:video|audio|pdf|torrent):)([^\]\n]{1,160})\]\(\s*(&[^)\s]+\.sha256|\/blob\/sealed\/[^)\s]{1,600})\s*\)/g;
|
||||
const MD_MENTION_RE = /\[@([^\]\[\n]{1,120})\]\(@?([A-Za-z0-9+/=.\-]{1,120}\.ed25519)\)/g;
|
||||
const RAW_MENTION_RE = /@([A-Za-z0-9+/=.\-]+\.ed25519)/g;
|
||||
const BARE_MENTION_RE = /(^|[^\w\u0080-\uFFFF\[\]/@])@([\w\u0080-\uFFFF.\-]{1,64})(?=$|[^\w\u0080-\uFFFF/+=])/g;
|
||||
let mentionResolver = null;
|
||||
const setMentionResolver = (fn) => { mentionResolver = typeof fn === 'function' ? fn : null; };
|
||||
const bareMentionName = (token) => (/\.ed25519$/i.test(token) ? '' : token.replace(/[.\-]+$/, ''));
|
||||
const MSG_REF_RE = /%[A-Za-z0-9+/=]{44}\.sha256/g;
|
||||
const MD_LINK_RE = /!?\[([^\]\n]{1,160})\]\((https?:\/\/[^)\s]+|\/(?![\/\\])[^)\s]*)\)/g;
|
||||
const URL_RE = /\b(?:https?:\/\/|www\.)[^\s<>"'`]+/gi;
|
||||
const EMAIL_RE = /\b[A-Za-z0-9][A-Za-z0-9._%+-]{0,63}@(?:[A-Za-z0-9-]{1,63}\.)+[A-Za-z]{2,24}\b/g;
|
||||
const HASHTAG_RE = /#[\w\u0080-\uFFFF]{1,32}(?![\w\u0080-\uFFFF])/g;
|
||||
const URL_TAIL_RE = /[.,;:!?»"')\]}>]+$/;
|
||||
const SELF_HOST_RE = /^https?:\/\/(?:localhost|127\.0\.0\.1|\[::1\])(?::\d+)?(?:[/?#]|$)/i;
|
||||
const INTERNAL_PATHS = [
|
||||
'agenda','ai','audios','author','backup','banking','blockexplorer','blogs','bookmarks','calendars','campaigns',
|
||||
'chats','cipher','courts','cv','dev','docs','emergencies','events','favorites','feed','files','forum','games','graphos',
|
||||
'hashtag','housing','images','industry','inbox','inhabitants','invites','jobs','larp','logistics','logs','mailing',
|
||||
'maps','market','melody','mentions','modules','multiverse','opinions','pads','parliament','rooms','peers','pixelia','pm',
|
||||
'podcasts','polls','popular','profile','projects','publish','reports','school','search','settings','shops','spread',
|
||||
'stats','tags','tasks','thread','torrents','transfers','trending','tribes','tribe','videos','votes','votations','wallet','wiki'
|
||||
];
|
||||
const INTERNAL_PATH_SET = new Set(INTERNAL_PATHS);
|
||||
const SSB_ID_RE = /^(?:%[A-Za-z0-9+/=]{20,}\.sha256|@[A-Za-z0-9+/=]{20,}\.ed25519|&[A-Za-z0-9+/=]{20,}\.sha256)$/;
|
||||
|
||||
const internalTargetOf = (url) => {
|
||||
let parsed = null;
|
||||
try { parsed = new URL(url); } catch (_) { return null; }
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return null;
|
||||
const segments = parsed.pathname.split('/').filter(Boolean).map(seg => { try { return decodeURIComponent(seg); } catch (_) { return seg; } });
|
||||
if (!segments.length || !INTERNAL_PATH_SET.has(segments[0].toLowerCase())) return null;
|
||||
const sameHost = SELF_HOST_RE.test(url);
|
||||
const hasSsbId = segments.slice(1).some(seg => SSB_ID_RE.test(seg));
|
||||
if (!sameHost && !hasSsbId) return null;
|
||||
const id = segments.slice(1).find(seg => SSB_ID_RE.test(seg)) || segments[1] || '';
|
||||
return { href: `${parsed.pathname}${parsed.search}${parsed.hash}`, kind: segments[0].toLowerCase(), id };
|
||||
};
|
||||
|
||||
const shortInternalId = (id) => {
|
||||
const value = String(id || '');
|
||||
if (!value) return '';
|
||||
const sigil = /^[%@&]/.test(value) ? value[0] : '';
|
||||
const body = sigil ? value.slice(1) : value;
|
||||
return body.length > 8 ? `${sigil}${body.slice(0, 8)}…` : `${sigil}${body}`;
|
||||
};
|
||||
const MAX_DEPTH = 8;
|
||||
|
||||
const headerLevel = (hashes) => Math.min(3, String(hashes || '#').length);
|
||||
|
||||
const indentLevel = (indent) => {
|
||||
const width = String(indent || '').replace(/\t/g, ' ').length;
|
||||
return Math.min(4, Math.floor(width / 2) + 1);
|
||||
};
|
||||
|
||||
const codeBlockBody = (raw) => {
|
||||
const s = String(raw);
|
||||
const nl = s.indexOf('\n');
|
||||
if (nl === -1) return s;
|
||||
return s.slice(nl + 1).replace(/\n[ \t]*$/, '');
|
||||
};
|
||||
|
||||
function renderStyledText(value, opts = {}) {
|
||||
if (typeof value !== 'string') return value === null || value === undefined ? [] : [String(value)];
|
||||
const text = value.indexOf('\r') === -1 ? value : value.replace(/\r\n?/g, '\n');
|
||||
const depth = Number(opts.depth) || 0;
|
||||
if (depth > MAX_DEPTH) return [text];
|
||||
const allMatches = [];
|
||||
const push = (m, extra) => allMatches.push({ index: m.index, length: m[0].length, ...extra });
|
||||
for (const m of text.matchAll(ESCAPE_RE)) push(m, { type: 'escaped', body: m[1] });
|
||||
for (const m of text.matchAll(WIKILINK_RE)) push(m, { type: 'wikilink', target: linkTarget(m[1]), label: (m[2] || linkTarget(m[1])).trim() });
|
||||
for (const m of text.matchAll(CODE_BLOCK_RE)) push(m, { type: 'code-block', body: codeBlockBody(m[1]) });
|
||||
for (const m of text.matchAll(RULE_RE)) push(m, { type: 'rule' });
|
||||
for (const m of text.matchAll(HEADER_CLOSED_RE)) push(m, { type: 'header', level: headerLevel(m[1]), body: m[2] });
|
||||
for (const m of text.matchAll(HEADER_SPACED_RE)) push(m, { type: 'header', level: headerLevel(m[1]), body: m[2] });
|
||||
for (const m of text.matchAll(QUOTE_RE)) push(m, { type: 'quote', body: m[1] });
|
||||
for (const m of text.matchAll(BULLET_RE)) push(m, { type: 'bullet', level: indentLevel(m[1]), body: m[2] });
|
||||
for (const m of text.matchAll(NUMBER_RE)) push(m, { type: 'number', level: indentLevel(m[1]), marker: `${m[2]}${m[3]}`, body: m[4] });
|
||||
for (const m of text.matchAll(BLOB_IMAGE_RE)) push(m, { type: 'blob-image', name: m[1], blob: m[2] });
|
||||
for (const m of text.matchAll(BLOB_VIDEO_RE)) push(m, { type: 'blob-video', name: m[1], blob: m[2] });
|
||||
for (const m of text.matchAll(BLOB_AUDIO_RE)) push(m, { type: 'blob-audio', name: m[1], blob: m[2] });
|
||||
for (const m of text.matchAll(BLOB_PDF_RE)) push(m, { type: 'blob-pdf', name: m[1], blob: m[2] });
|
||||
for (const m of text.matchAll(BLOB_TORRENT_RE)) push(m, { type: 'blob-file', name: m[1], blob: m[2] });
|
||||
for (const m of text.matchAll(BLOB_FILE_RE)) push(m, { type: 'blob-file', name: m[1], blob: m[2] });
|
||||
for (const m of text.matchAll(MD_MENTION_RE)) push(m, { type: 'md-mention', name: m[1], feedId: m[2] });
|
||||
for (const m of text.matchAll(RAW_MENTION_RE)) push(m, { type: 'raw-mention', feedId: m[1] });
|
||||
for (const m of text.matchAll(BARE_MENTION_RE)) {
|
||||
const name = bareMentionName(m[2]);
|
||||
if (name) allMatches.push({ index: m.index + m[1].length, length: name.length + 1, type: 'bare-mention', name });
|
||||
}
|
||||
for (const m of text.matchAll(MSG_REF_RE)) push(m, { type: 'msg-ref', id: m[0] });
|
||||
for (const m of text.matchAll(MD_LINK_RE)) push(m, { type: 'md-link', label: m[1], href: m[2] });
|
||||
for (const m of text.matchAll(BOLD_RE)) push(m, { type: 'bold', body: m[1] });
|
||||
for (const m of text.matchAll(UNDERLINE_RE)) push(m, { type: 'underline', body: m[1] });
|
||||
for (const m of text.matchAll(STRIKE_RE)) push(m, { type: 'strike', body: m[1] });
|
||||
for (const m of text.matchAll(ITALIC_RE)) push(m, { type: 'italic', body: m[1] });
|
||||
for (const m of text.matchAll(INLINE_CODE_RE)) push(m, { type: 'inline-code', body: m[1] });
|
||||
for (const m of text.matchAll(URL_RE)) {
|
||||
const trimmed = m[0].replace(URL_TAIL_RE, '');
|
||||
if (trimmed) allMatches.push({ index: m.index, length: trimmed.length, type: 'url', text: trimmed });
|
||||
}
|
||||
for (const m of text.matchAll(EMAIL_RE)) push(m, { type: 'email', text: m[0] });
|
||||
for (const m of text.matchAll(HASHTAG_RE)) push(m, { type: 'hashtag', tag: m[0].slice(1) });
|
||||
allMatches.sort((x, y) => x.index - y.index);
|
||||
const filtered = [];
|
||||
let lastEnd = 0;
|
||||
for (const m of allMatches) {
|
||||
if (m.index < lastEnd) continue;
|
||||
filtered.push(m);
|
||||
lastEnd = m.index + m.length;
|
||||
}
|
||||
const blobHref = (id) => String(id).startsWith('/blob/sealed/') ? String(id) : `${opts.blobPrefix || '/blob/'}${encodeURIComponent(id)}`;
|
||||
const linksOn = opts.links !== false;
|
||||
const internalOn = opts.internalLinks !== false;
|
||||
const plain = (value) => opts.plainUrlClass ? span({ class: opts.plainUrlClass }, value) : value;
|
||||
const inner = (body) => renderStyledText(body, { ...opts, depth: depth + 1 });
|
||||
const result = [];
|
||||
let cursor = 0;
|
||||
for (const m of filtered) {
|
||||
if (cursor < m.index) result.push(text.slice(cursor, m.index));
|
||||
if (m.type === 'escaped') {
|
||||
result.push(m.body);
|
||||
} else if (m.type === 'msg-ref') {
|
||||
result.push(internalOn ? a({ href: `/thread/${encodeURIComponent(m.id)}`, class: 'styled-link' }, m.id.slice(0, 10) + '...') : m.id);
|
||||
} else if (m.type === 'wikilink') {
|
||||
result.push(typeof opts.wikiLink === 'function'
|
||||
? opts.wikiLink(m.target, slugify(m.target), m.label)
|
||||
: internalOn
|
||||
? a({ href: `/wiki/${encodeURIComponent(slugify(m.target))}`, class: 'wiki-link' }, m.label)
|
||||
: m.label);
|
||||
} else if (m.type === 'code-block') {
|
||||
result.push(span({ class: 'rt-code-block' }, m.body));
|
||||
} else if (m.type === 'rule') {
|
||||
result.push(span({ class: 'rt-rule' }));
|
||||
} else if (m.type === 'header') {
|
||||
result.push(span({ class: `rt-header rt-header-${m.level}` }, ...inner(m.body)));
|
||||
} else if (m.type === 'quote') {
|
||||
result.push(span({ class: 'rt-quote' }, ...inner(m.body)));
|
||||
} else if (m.type === 'bullet') {
|
||||
result.push(span({ class: `rt-item rt-item-${m.level}` }, ...inner(m.body)));
|
||||
} else if (m.type === 'number') {
|
||||
result.push(span({ class: `rt-item rt-item-number rt-item-${m.level}` }, `${m.marker} `, ...inner(m.body)));
|
||||
} else if (m.type === 'bold') {
|
||||
result.push(strong(...inner(m.body)));
|
||||
} else if (m.type === 'underline') {
|
||||
result.push(u(...inner(m.body)));
|
||||
} else if (m.type === 'strike') {
|
||||
result.push(strike(...inner(m.body)));
|
||||
} else if (m.type === 'italic') {
|
||||
result.push(em(...inner(m.body)));
|
||||
} else if (m.type === 'inline-code') {
|
||||
result.push(code({ class: 'rt-code' }, m.body));
|
||||
} else if (m.type === 'blob-image') {
|
||||
const imageSrc = blobHref(m.blob);
|
||||
if (opts.zoomImages) {
|
||||
zoomSeq = (zoomSeq + 1) % 1000000;
|
||||
const zoomId = `rtzoom-${zoomSeq}-${String(m.blob).replace(/[^a-zA-Z0-9]/g, '').slice(-10)}`;
|
||||
result.push(a({ href: `#${zoomId}`, id: `${zoomId}-src`, class: 'zoom-link' }, img({ src: imageSrc, alt: m.name || '', class: 'post-image' })));
|
||||
result.push(span({ id: zoomId, class: 'lightbox' },
|
||||
a({ href: `${imageSrc}${imageSrc.includes('?') ? '&' : '?'}download=1`, class: 'lightbox-download', title: getI18n().fileShareDownload || 'Download' }, '\u2913'),
|
||||
a({ href: `#${zoomId}-src`, class: 'lightbox-close' }, '\u00d7'),
|
||||
img({ src: imageSrc, alt: m.name || '', class: 'lightbox-image' })
|
||||
));
|
||||
} else {
|
||||
result.push(img({ src: imageSrc, alt: m.name || '', class: 'post-image' }));
|
||||
}
|
||||
} else if (m.type === 'blob-video') {
|
||||
result.push(video({ controls: true, class: 'post-video', src: blobHref(m.blob) }));
|
||||
} else if (m.type === 'blob-audio') {
|
||||
result.push(audio({ controls: true, class: 'post-audio', src: blobHref(m.blob) }));
|
||||
} else if (m.type === 'blob-pdf') {
|
||||
const i18n = getI18n();
|
||||
result.push(a({ href: blobHref(m.blob), class: 'post-pdf', target: '_blank', rel: 'noopener noreferrer' }, m.name || i18n.pdfFallbackLabel || 'PDF'));
|
||||
} else if (m.type === 'blob-file') {
|
||||
result.push(a({ href: blobHref(m.blob), class: 'post-pdf', target: '_blank', rel: 'noopener noreferrer' }, m.name || m.blob.slice(1, 9)));
|
||||
} else if (m.type === 'md-mention') {
|
||||
result.push(internalOn ? a({ href: `/author/${encodeURIComponent('@' + m.feedId)}`, class: 'mention' }, '@' + m.name) : '@' + m.name);
|
||||
} else if (m.type === 'raw-mention') {
|
||||
result.push(internalOn ? a({ href: `/author/${encodeURIComponent('@' + m.feedId)}`, class: 'mention' }, '@' + m.feedId.slice(0, 8) + '...') : '@' + m.feedId.slice(0, 8) + '...');
|
||||
} else if (m.type === 'bare-mention') {
|
||||
const feedId = internalOn && mentionResolver ? mentionResolver(m.name) : null;
|
||||
result.push(feedId ? a({ href: `/author/${encodeURIComponent(feedId)}`, class: 'mention' }, '@' + m.name) : '@' + m.name);
|
||||
} else if (m.type === 'md-link') {
|
||||
const external = /^https?:\/\//.test(m.href) && !SELF_HOST_RE.test(m.href);
|
||||
result.push(external
|
||||
? (linksOn ? a({ href: m.href, class: 'styled-link', target: '_blank', rel: 'noopener noreferrer' }, ...inner(m.label)) : plain(m.label))
|
||||
: (internalOn ? a({ href: m.href, class: 'styled-link' }, ...inner(m.label)) : m.label));
|
||||
} else if (m.type === 'url') {
|
||||
const schemeMatch = m.text.match(/^(https?):\/\//i);
|
||||
const href = schemeMatch ? `${schemeMatch[1].toLowerCase()}://${m.text.slice(schemeMatch[0].length)}` : `https://${m.text}`;
|
||||
const internalTarget = internalTargetOf(href);
|
||||
if (!linksOn) result.push(plain(m.text));
|
||||
else if (internalTarget) {
|
||||
result.push(internalOn
|
||||
? a({ href: internalTarget.href, class: 'internal-link' },
|
||||
span({ class: 'internal-link-kind' }, internalTarget.kind.toUpperCase()),
|
||||
internalTarget.id ? span({ class: 'internal-link-id' }, shortInternalId(internalTarget.id)) : null
|
||||
)
|
||||
: plain(internalTarget.href));
|
||||
}
|
||||
else if (SELF_HOST_RE.test(href)) result.push(a({ href }, m.text));
|
||||
else result.push(a({ href, target: '_blank', rel: 'noopener noreferrer' }, m.text));
|
||||
} else if (m.type === 'email') {
|
||||
result.push(linksOn ? a({ href: `mailto:${m.text}` }, m.text) : plain(m.text));
|
||||
} else if (m.type === 'hashtag') {
|
||||
const tagHref = typeof opts.hashtagHref === 'function' ? opts.hashtagHref(m.tag) : (internalOn ? `/search?query=%23${encodeURIComponent(m.tag)}` : null);
|
||||
result.push(tagHref ? a({ href: tagHref, class: 'tag-link tag-inline' }, `#${m.tag}`) : `#${m.tag}`);
|
||||
}
|
||||
cursor = m.index + m.length;
|
||||
}
|
||||
if (cursor < text.length) result.push(text.slice(cursor));
|
||||
return result;
|
||||
}
|
||||
|
||||
const safeExternalHref = (url) => {
|
||||
const value = String(url || '').trim();
|
||||
const lower = value.toLowerCase();
|
||||
if (lower.startsWith('http://') || lower.startsWith('https://') || lower.startsWith('mailto:')) return value;
|
||||
return '';
|
||||
};
|
||||
|
||||
const escapeHtml = (value) => String(value)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
|
||||
function renderStyledHtml(text, opts = {}) {
|
||||
if (text === null || text === undefined || text === '') return '';
|
||||
return renderStyledText(String(text), opts)
|
||||
.map(node => (node && typeof node === 'object' && node.outerHTML) ? node.outerHTML : escapeHtml(node))
|
||||
.join('');
|
||||
}
|
||||
|
||||
function plainText(text) {
|
||||
if (text === null || text === undefined) return '';
|
||||
return String(text)
|
||||
.replace(/```[\s\S]*?```/g, '')
|
||||
.replace(/^[ \t]*>[ \t]?/gm, '')
|
||||
.replace(/^[ \t]*---[ \t]*$/gm, '')
|
||||
.replace(/^[ \t]*#{1,6}[ \t]*/gm, '')
|
||||
.replace(/[ \t]*#{1,6}[ \t]*$/gm, '')
|
||||
.replace(/^[ \t]*- /gm, '')
|
||||
.replace(/^[ \t]*\d{1,3}[.)] /gm, '')
|
||||
.replace(/\*\*(.*?)\*\*/g, '$1')
|
||||
.replace(/__(.*?)__/g, '$1')
|
||||
.replace(/~~(.*?)~~/g, '$1')
|
||||
.replace(/\*(.*?)\*/g, '$1')
|
||||
.replace(/`([^`]+)`/g, '$1')
|
||||
.replace(/!?\[[^\]\n]{0,160}\]\(\s*&[^)\s\n]{1,120}\.sha256\s*\)/g, '')
|
||||
.replace(/\[(?:video|audio|pdf):[^\]\n]{0,160}\]\([^)\n]{0,200}\)/g, '')
|
||||
.replace(/\[\[([^\]|\n]{1,120})(?:\|([^\]\n]{1,120}))?\]\]/g, (m, target, label) => label || target)
|
||||
.replace(/!?\[([^\]\n]{1,160})\]\(([^)\s\n]{0,200})\)/g, (m, label, href) => href ? `${label} (${href})` : label)
|
||||
.replace(/\\([\\*_~`#>\-\[\]!])/g, '$1')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function renderTextPreview(text, maxLength = 220) {
|
||||
const preview = plainText(text).replace(/\n+/g, ' ').trim();
|
||||
return preview.length > maxLength ? preview.slice(0, maxLength) + '...' : preview;
|
||||
}
|
||||
|
||||
const FORMAT_MARKS = [
|
||||
{ mark: '**bold**', style: 'bold' },
|
||||
{ mark: '*italic*', style: 'italic' },
|
||||
{ mark: '__underline__', style: 'underline' },
|
||||
{ mark: '~~strike~~', style: 'strike' },
|
||||
{ mark: '`code`', style: 'code' },
|
||||
{ mark: '### title ###', style: 'header' },
|
||||
{ mark: '- list', style: 'item' },
|
||||
{ mark: '> quote', style: 'quote' },
|
||||
{ mark: '[[wiki]]', style: 'wiki' }
|
||||
];
|
||||
|
||||
function renderFormatBar() {
|
||||
const i18n = getI18n();
|
||||
const marks = [];
|
||||
FORMAT_MARKS.forEach((m, i) => {
|
||||
if (i) marks.push(span({ class: 'rt-sep' }, '|'));
|
||||
marks.push(span({ class: `rt-mark rt-mark-${m.style}` }, m.mark));
|
||||
});
|
||||
return div({ class: 'rt-toolbar' },
|
||||
span({ class: 'rt-toolbar-label' }, `${i18n.formatBarLabel || 'Format'}:`),
|
||||
...marks
|
||||
);
|
||||
}
|
||||
|
||||
function richTextarea(attrs, ...children) {
|
||||
return [renderFormatBar(), textarea(attrs, ...children)];
|
||||
}
|
||||
|
||||
module.exports = { renderStyledText, renderStyledHtml, renderTextPreview, plainText, escapeHtml, safeExternalHref, renderFormatBar, richTextarea, setMentionResolver, INTERNAL_PATHS };
|
||||
139
src/backend/renderTextWithStyles.js
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
const i18nBase = require("../client/assets/translations/i18n");
|
||||
|
||||
function getI18n() {
|
||||
try {
|
||||
const { i18n } = require("../views/main_views");
|
||||
return i18n;
|
||||
} catch (_) {
|
||||
return i18nBase['en'] || {};
|
||||
}
|
||||
}
|
||||
|
||||
function renderTextPreview(text, maxLength = 220) {
|
||||
if (!text) return ''
|
||||
|
||||
let preview = String(text)
|
||||
|
||||
preview = preview
|
||||
.replace(/```[\s\S]*?```/g, '')
|
||||
.replace(/^>.*$/gm, '')
|
||||
.replace(/^#{1,6}\s*/gm, '')
|
||||
.replace(/^- /gm, '')
|
||||
.replace(/^\d+\. /gm, '')
|
||||
.replace(/\*\*(.*?)\*\*/g, '$1')
|
||||
.replace(/\*(.*?)\*/g, '$1')
|
||||
.replace(/`([^`]+)`/g, '$1')
|
||||
.replace(/!\[.*?\]\(.*?\)/g, '')
|
||||
.replace(/\[.*?\]\(.*?\)/g, '')
|
||||
.replace(/\n+/g, ' ')
|
||||
.trim()
|
||||
|
||||
if (preview.length > maxLength) {
|
||||
preview = preview.slice(0, maxLength) + '...'
|
||||
}
|
||||
|
||||
return preview
|
||||
}
|
||||
|
||||
function renderTextWithStyles(text) {
|
||||
if (!text) return ''
|
||||
const i18n = getI18n()
|
||||
|
||||
let html = String(text)
|
||||
|
||||
html = html
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
|
||||
html = html
|
||||
.replace(/```([\s\S]*?)```/gim, '<pre><code>$1</code></pre>')
|
||||
.replace(/^> (.*)$/gim, '<blockquote>$1</blockquote>')
|
||||
.replace(/^---$/gim, '<hr/>')
|
||||
.replace(/^### (.*)$/gim, '<h3>$1</h3>')
|
||||
.replace(/^## (.*)$/gim, '<h2>$1</h2>')
|
||||
.replace(/^# (.*)$/gim, '<h1>$1</h1>')
|
||||
|
||||
html = html
|
||||
.replace(/\*\*(.*?)\*\*/gim, '<strong>$1</strong>')
|
||||
.replace(/\*(.*?)\*/gim, '<em>$1</em>')
|
||||
.replace(/`([^`]+)`/gim, '<code>$1</code>')
|
||||
|
||||
html = html
|
||||
.replace(/!\[([^\]]*)\]\(\s*(&[^)\s]+\.sha256)\s*\)/g, (_, alt, blob) =>
|
||||
`<img src="/blob/${encodeURIComponent(blob.replace(/&/g, '&'))}" alt="${alt}" class="post-image" />`
|
||||
)
|
||||
.replace(/\[video:([^\]]*)\]\(\s*(&[^)\s]+\.sha256)\s*\)/g, (_, _name, blob) =>
|
||||
`<video controls class="post-video" src="/blob/${encodeURIComponent(blob.replace(/&/g, '&'))}"></video>`
|
||||
)
|
||||
.replace(/\[audio:([^\]]*)\]\(\s*(&[^)\s]+\.sha256)\s*\)/g, (_, _name, blob) =>
|
||||
`<audio controls class="post-audio" src="/blob/${encodeURIComponent(blob.replace(/&/g, '&'))}"></audio>`
|
||||
)
|
||||
.replace(/\[pdf:([^\]]*)\]\(\s*(&[^)\s]+\.sha256)\s*\)/g, (_, name, blob) =>
|
||||
`<a class="post-pdf" href="/blob/${encodeURIComponent(blob.replace(/&/g, '&'))}" target="_blank">${name || i18n.pdfFallbackLabel || 'PDF'}</a>`
|
||||
)
|
||||
|
||||
html = html
|
||||
.replace(/\[@([^\]]+)\]\(@?([A-Za-z0-9+/=.\-]+\.ed25519)\)/g, (_, name, id) =>
|
||||
`<a href="/author/${encodeURIComponent('@' + id)}" class="mention" target="_blank">@${name}</a>`
|
||||
)
|
||||
.replace(/@([A-Za-z0-9+/=.\-]+\.ed25519)/g, (_, id) =>
|
||||
`<a href="/author/${encodeURIComponent('@' + id)}" class="mention" target="_blank">@${id}</a>`
|
||||
)
|
||||
|
||||
const escAttr = (s) => String(s).replace(/"/g, '"').replace(/'/g, ''')
|
||||
html = html
|
||||
.replace(/#(\w+)/g, (_, tag) =>
|
||||
`<a href="/hashtag/${encodeURIComponent(tag)}" class="styled-link" target="_blank">#${escAttr(tag)}</a>`
|
||||
)
|
||||
.replace(/(https?:\/\/[^\s"'<>]+)/g, url =>
|
||||
`<a href="${escAttr(url)}" target="_blank" class="styled-link">${escAttr(url)}</a>`
|
||||
)
|
||||
.replace(/([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/g, email =>
|
||||
`<a href="mailto:${escAttr(email)}" class="styled-link">${escAttr(email)}</a>`
|
||||
)
|
||||
|
||||
const lines = html.split('\n')
|
||||
let result = ''
|
||||
let inUL = false
|
||||
let inOL = false
|
||||
|
||||
for (let line of lines) {
|
||||
if (/^- /.test(line)) {
|
||||
if (!inUL) {
|
||||
result += '<ul>'
|
||||
inUL = true
|
||||
}
|
||||
result += `<li>${line.replace(/^- /, '')}</li>`
|
||||
continue
|
||||
}
|
||||
|
||||
if (/^\d+\. /.test(line)) {
|
||||
if (!inOL) {
|
||||
result += '<ol>'
|
||||
inOL = true
|
||||
}
|
||||
result += `<li>${line.replace(/^\d+\. /, '')}</li>`
|
||||
continue
|
||||
}
|
||||
|
||||
if (inUL) {
|
||||
result += '</ul>'
|
||||
inUL = false
|
||||
}
|
||||
|
||||
if (inOL) {
|
||||
result += '</ol>'
|
||||
inOL = false
|
||||
}
|
||||
|
||||
result += line + '<br>'
|
||||
}
|
||||
|
||||
if (inUL) result += '</ul>'
|
||||
if (inOL) result += '</ol>'
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
module.exports = { renderTextWithStyles, renderTextPreview }
|
||||
92
src/backend/renderUrl.js
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
const { a, img, video, audio } = require("../server/node_modules/hyperaxe");
|
||||
const i18nBase = require("../client/assets/translations/i18n");
|
||||
|
||||
function getI18n() {
|
||||
try {
|
||||
const { i18n } = require("../views/main_views");
|
||||
return i18n;
|
||||
} catch (_) {
|
||||
return i18nBase['en'] || {};
|
||||
}
|
||||
}
|
||||
|
||||
function renderUrl(text) {
|
||||
if (typeof text !== 'string') return [text];
|
||||
const blobImageRegex = /!\[([^\]]*)\]\(\s*(&[^)\s]+\.sha256)\s*\)/g;
|
||||
const blobVideoRegex = /\[video:([^\]]*)\]\(\s*(&[^)\s]+\.sha256)\s*\)/g;
|
||||
const blobAudioRegex = /\[audio:([^\]]*)\]\(\s*(&[^)\s]+\.sha256)\s*\)/g;
|
||||
const blobPdfRegex = /\[pdf:([^\]]*)\]\(\s*(&[^)\s]+\.sha256)\s*\)/g;
|
||||
const mdMentionRegex = /\[@([^\]]+)\]\(@?([A-Za-z0-9+/=.\-]+\.ed25519)\)/g;
|
||||
const rawMentionRegex = /@([A-Za-z0-9+/=.\-]+\.ed25519)/g;
|
||||
const urlRegex = /\b(?:https?:\/\/|www\.)[^\s]+/g;
|
||||
const emailRegex = /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z]{2,}\b/gi;
|
||||
const allMatches = [];
|
||||
for (const m of text.matchAll(blobImageRegex)) {
|
||||
allMatches.push({ index: m.index, length: m[0].length, type: 'blob-image', name: m[1], blob: m[2] });
|
||||
}
|
||||
for (const m of text.matchAll(blobVideoRegex)) {
|
||||
allMatches.push({ index: m.index, length: m[0].length, type: 'blob-video', name: m[1], blob: m[2] });
|
||||
}
|
||||
for (const m of text.matchAll(blobAudioRegex)) {
|
||||
allMatches.push({ index: m.index, length: m[0].length, type: 'blob-audio', name: m[1], blob: m[2] });
|
||||
}
|
||||
for (const m of text.matchAll(blobPdfRegex)) {
|
||||
allMatches.push({ index: m.index, length: m[0].length, type: 'blob-pdf', name: m[1], blob: m[2] });
|
||||
}
|
||||
for (const m of text.matchAll(mdMentionRegex)) {
|
||||
allMatches.push({ index: m.index, length: m[0].length, type: 'md-mention', name: m[1], feedId: m[2] });
|
||||
}
|
||||
for (const m of text.matchAll(rawMentionRegex)) {
|
||||
allMatches.push({ index: m.index, length: m[0].length, type: 'raw-mention', feedId: m[1] });
|
||||
}
|
||||
for (const m of text.matchAll(urlRegex)) {
|
||||
allMatches.push({ index: m.index, length: m[0].length, type: 'url', text: m[0] });
|
||||
}
|
||||
for (const m of text.matchAll(emailRegex)) {
|
||||
allMatches.push({ index: m.index, length: m[0].length, type: 'email', text: m[0] });
|
||||
}
|
||||
allMatches.sort((a, b) => a.index - b.index);
|
||||
const filtered = [];
|
||||
let lastEnd = 0;
|
||||
for (const m of allMatches) {
|
||||
if (m.index < lastEnd) continue;
|
||||
filtered.push(m);
|
||||
lastEnd = m.index + m.length;
|
||||
}
|
||||
const result = [];
|
||||
let cursor = 0;
|
||||
for (const m of filtered) {
|
||||
if (cursor < m.index) {
|
||||
result.push(text.slice(cursor, m.index));
|
||||
}
|
||||
if (m.type === 'blob-image') {
|
||||
result.push(img({ src: `/blob/${encodeURIComponent(m.blob)}`, alt: m.name || '', class: 'post-image' }));
|
||||
} else if (m.type === 'blob-video') {
|
||||
result.push(video({ controls: true, class: 'post-video', src: `/blob/${encodeURIComponent(m.blob)}` }));
|
||||
} else if (m.type === 'blob-audio') {
|
||||
result.push(audio({ controls: true, class: 'post-audio', src: `/blob/${encodeURIComponent(m.blob)}` }));
|
||||
} else if (m.type === 'blob-pdf') {
|
||||
const i18n = getI18n();
|
||||
const label = m.name || i18n.pdfFallbackLabel || 'PDF';
|
||||
result.push(a({ href: `/blob/${encodeURIComponent(m.blob)}`, class: 'post-pdf', target: '_blank' }, label));
|
||||
} else if (m.type === 'md-mention') {
|
||||
const feedWithAt = '@' + m.feedId;
|
||||
result.push(a({ href: `/author/${encodeURIComponent(feedWithAt)}`, class: 'mention' }, '@' + m.name));
|
||||
} else if (m.type === 'raw-mention') {
|
||||
const feedWithAt = '@' + m.feedId;
|
||||
result.push(a({ href: `/author/${encodeURIComponent(feedWithAt)}`, class: 'mention' }, '@' + m.feedId.slice(0, 8) + '...'));
|
||||
} else if (m.type === 'url') {
|
||||
const href = m.text.startsWith('http') ? m.text : `https://${m.text}`;
|
||||
result.push(a({ href, target: '_blank', rel: 'noopener noreferrer' }, m.text));
|
||||
} else if (m.type === 'email') {
|
||||
result.push(a({ href: `mailto:${m.text}` }, m.text));
|
||||
}
|
||||
cursor = m.index + m.length;
|
||||
}
|
||||
if (cursor < text.length) {
|
||||
result.push(text.slice(cursor));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
module.exports = { renderUrl };
|
||||
|
|
@ -1,99 +0,0 @@
|
|||
const MSG_KEY_RE = /^%[A-Za-z0-9+/]{43}=\.sha256$/;
|
||||
|
||||
const isMsgKey = (value) => typeof value === 'string' && MSG_KEY_RE.test(value);
|
||||
|
||||
const pickMsgKeys = (value) => {
|
||||
const list = Array.isArray(value) ? value : (value == null ? [] : [value]);
|
||||
return Array.from(new Set(list.filter(isMsgKey)));
|
||||
};
|
||||
|
||||
const isLocalPath = (value) => {
|
||||
if (typeof value !== 'string' || !value.startsWith('/')) return false;
|
||||
if (value.startsWith('//') || value.startsWith('/\\')) return false;
|
||||
if (/[\r\n\0]/.test(value)) return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
const SHARED_RETURN_PAGES = ['/activity', '/agenda', '/search', '/trending', '/favorites', '/opinions', '/mentions', '/melody', '/author', '/inhabitant', '/tribe'];
|
||||
const isSharedReturnPage = (rt) => SHARED_RETURN_PAGES.some(p => rt === p || (rt.startsWith(p) && /^[?#/]/.test(rt.slice(p.length))));
|
||||
const safeReturnTo = (ctx, fallback, allowedPrefixes) => {
|
||||
const rt = ctx?.request?.body?.returnTo || ctx?.query?.returnTo;
|
||||
if (!isLocalPath(rt)) return fallback;
|
||||
if (isSharedReturnPage(rt)) return rt;
|
||||
return Array.isArray(allowedPrefixes) && allowedPrefixes.some(p => rt.startsWith(p)) ? rt : fallback;
|
||||
};
|
||||
|
||||
const refererPath = (referer, host) => {
|
||||
if (!referer) return null;
|
||||
try {
|
||||
const u = new URL(referer);
|
||||
if ((u.protocol !== 'http:' && u.protocol !== 'https:') || u.host !== host) return null;
|
||||
const target = u.pathname + u.search + u.hash;
|
||||
return isLocalPath(target) ? target : null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const safeRefererRedirect = (ctx, fallback = '/') => {
|
||||
const target = refererPath(ctx.request.header.referer, ctx.host);
|
||||
ctx.redirect(target || fallback);
|
||||
};
|
||||
|
||||
const isClearnetPath = (request) => {
|
||||
const url = String((request && request.url) || '');
|
||||
return url === '/c' || url.startsWith('/c/') || url.startsWith('/c?');
|
||||
};
|
||||
|
||||
const isTrustedRequest = (request, validHosts) => {
|
||||
if (isClearnetPath(request)) return request.method === 'GET';
|
||||
if (!Array.isArray(validHosts) || !validHosts.includes(request.hostname)) return false;
|
||||
if (request.method === 'GET') return true;
|
||||
const origin = request.header && request.header.origin;
|
||||
const source = origin && origin !== 'null' ? origin : (request.header && request.header.referer);
|
||||
if (source == null) return false;
|
||||
try {
|
||||
const u = new URL(source);
|
||||
if ((u.protocol !== 'http:' && u.protocol !== 'https:') || !validHosts.includes(u.hostname)) return false;
|
||||
if (u.host !== String(request.host || '')) return false;
|
||||
if (u.pathname.startsWith('/blob/')) return false;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const publicModeGuard = ({ isPublic, onBlocked }) => async (ctx, next) => {
|
||||
if (isPublic && ctx.method !== 'GET') { onBlocked(ctx); return; }
|
||||
await next();
|
||||
};
|
||||
|
||||
const buildCsp = (isClearnet, { frames = false } = {}) => (isClearnet
|
||||
? [
|
||||
"default-src 'self'",
|
||||
"script-src 'none'",
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
"img-src 'self' data:",
|
||||
"media-src 'self' blob:",
|
||||
"connect-src 'self'",
|
||||
"frame-src 'none'",
|
||||
"form-action 'self'",
|
||||
"object-src 'none'",
|
||||
"base-uri 'none'",
|
||||
"frame-ancestors 'none'"
|
||||
]
|
||||
: [
|
||||
"default-src 'self'",
|
||||
"script-src 'self' http://localhost:3000/js",
|
||||
"style-src 'self'",
|
||||
"img-src 'self'",
|
||||
"media-src 'self' blob:",
|
||||
"worker-src 'self' blob:",
|
||||
frames ? "frame-src 'self'" : "frame-src 'none'",
|
||||
"form-action 'self'",
|
||||
"object-src 'none'",
|
||||
"base-uri 'none'",
|
||||
"frame-ancestors 'none'"
|
||||
]).join('; ');
|
||||
|
||||
module.exports = { isMsgKey, pickMsgKeys, isLocalPath, safeReturnTo, refererPath, safeRefererRedirect, isClearnetPath, isTrustedRequest, publicModeGuard, buildCsp };
|
||||
|
|
@ -1,32 +1,14 @@
|
|||
"use strict";
|
||||
|
||||
const SAFE_SRC = /^\/(?:blob|image)\/[^\s"'<>]+$/i;
|
||||
const SAFE_HREF = /^(?:https?:\/\/|\/(?!\/)|#|mailto:)/i;
|
||||
const SAFE_CLASS = /^(?:rt-[a-z0-9-]+|styled-link|mention|tag-link|tag-inline|zoom-link|pm-quote|wiki-link(?:-missing)?|internal-link(?:-id|-kind)?|lightbox(?:-[a-z]+)?|post-(?:audio|image|pdf|video))$/;
|
||||
let purifier = null;
|
||||
const getPurify = () => {
|
||||
if (purifier) return purifier;
|
||||
const { JSDOM } = require('../server/node_modules/jsdom');
|
||||
const DOMPurify = require('../server/node_modules/dompurify');
|
||||
const purify = DOMPurify(new JSDOM('').window);
|
||||
purify.addHook('uponSanitizeAttribute', (node, data) => {
|
||||
const name = String(data.attrName || '').toLowerCase();
|
||||
const value = String(data.attrValue || '').trim();
|
||||
if (name === 'src' && !SAFE_SRC.test(value)) data.keepAttr = false;
|
||||
if (name === 'href' && !SAFE_HREF.test(value)) data.keepAttr = false;
|
||||
if (name === 'class') {
|
||||
const kept = value.split(/\s+/).filter(c => SAFE_CLASS.test(c));
|
||||
if (kept.length) data.attrValue = kept.join(' '); else data.keepAttr = false;
|
||||
}
|
||||
});
|
||||
purifier = purify;
|
||||
return purifier;
|
||||
};
|
||||
const dropControlChars = (html) => String(html).replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g, '');
|
||||
const { JSDOM } = require('../server/node_modules/jsdom');
|
||||
const DOMPurify = require('../server/node_modules/dompurify');
|
||||
const window = new JSDOM('').window;
|
||||
const purify = DOMPurify(window);
|
||||
|
||||
const stripDangerousTags = (input) => {
|
||||
if (typeof input !== 'string') return '';
|
||||
return dropControlChars(getPurify().sanitize(input, {
|
||||
return purify.sanitize(input, {
|
||||
USE_PROFILES: { html: true },
|
||||
ALLOWED_TAGS: [
|
||||
'p', 'br',
|
||||
'b', 'strong', 'i', 'em', 'u',
|
||||
|
|
@ -37,12 +19,13 @@ const stripDangerousTags = (input) => {
|
|||
ALLOWED_ATTR: [],
|
||||
FORBID_TAGS: ['svg', 'math', 'iframe', 'object', 'embed', 'form', 'input', 'textarea', 'select', 'button', 'script'],
|
||||
FORBID_ATTR: ['style']
|
||||
}));
|
||||
});
|
||||
};
|
||||
|
||||
const sanitizeHtml = (input) => {
|
||||
if (typeof input !== 'string') return '';
|
||||
return dropControlChars(getPurify().sanitize(input, {
|
||||
return purify.sanitize(input, {
|
||||
USE_PROFILES: { html: true },
|
||||
ALLOWED_TAGS: [
|
||||
'p', 'br', 'hr',
|
||||
'b', 'strong', 'i', 'em', 'u', 's', 'del',
|
||||
|
|
@ -56,7 +39,7 @@ const sanitizeHtml = (input) => {
|
|||
ALLOWED_ATTR: ['href', 'class', 'target', 'rel', 'src', 'alt', 'title', 'controls'],
|
||||
FORBID_TAGS: ['svg', 'math', 'iframe', 'object', 'embed', 'form', 'input', 'textarea', 'select', 'button', 'script', 'style', 'link', 'meta'],
|
||||
FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover', 'onfocus', 'onblur', 'onsubmit', 'onchange', 'style']
|
||||
}));
|
||||
});
|
||||
};
|
||||
|
||||
module.exports = { stripDangerousTags, sanitizeHtml };
|
||||
|
|
|
|||
228
src/backend/smartContractPdf.js
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const LOGO_PATH = path.join(__dirname, '..', 'client', 'assets', 'images', 'snh-oasis.jpg');
|
||||
|
||||
const escapePdf = s => String(s || '').replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)');
|
||||
|
||||
const wrap = (txt, max = 82) => {
|
||||
const out = [];
|
||||
for (const raw of String(txt || '').split('\n')) {
|
||||
let line = raw;
|
||||
while (line.length > max) {
|
||||
let cut = line.lastIndexOf(' ', max);
|
||||
if (cut <= 0) cut = max;
|
||||
out.push(line.slice(0, cut));
|
||||
line = line.slice(cut).replace(/^\s+/, '');
|
||||
}
|
||||
out.push(line);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
const readJpegDims = (buf) => {
|
||||
let i = 2;
|
||||
while (i < buf.length) {
|
||||
if (buf[i] !== 0xFF) return null;
|
||||
const marker = buf[i + 1];
|
||||
if (marker === 0xD8 || marker === 0xD9) { i += 2; continue; }
|
||||
const len = buf.readUInt16BE(i + 2);
|
||||
if (marker >= 0xC0 && marker <= 0xCF && marker !== 0xC4 && marker !== 0xC8 && marker !== 0xCC) {
|
||||
const h = buf.readUInt16BE(i + 5);
|
||||
const w = buf.readUInt16BE(i + 7);
|
||||
const c = buf[i + 9];
|
||||
return { w, h, c };
|
||||
}
|
||||
i += 2 + len;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
function buildSmartContractPdf({ transfer, block, viewerId }) {
|
||||
const pageW = 612;
|
||||
const pageH = 792;
|
||||
const marginX = 50;
|
||||
const headerH = 90;
|
||||
const footerH = 40;
|
||||
const bodyTop = pageH - headerH - 24;
|
||||
const bodyBottom = footerH + 10;
|
||||
const lineH = 14;
|
||||
|
||||
let logoBuf = null;
|
||||
let logoDims = null;
|
||||
try {
|
||||
logoBuf = fs.readFileSync(LOGO_PATH);
|
||||
logoDims = readJpegDims(logoBuf);
|
||||
} catch (_) {}
|
||||
|
||||
const t = transfer || {};
|
||||
const b = block || {};
|
||||
const fmt = v => (v === undefined || v === null) ? '' : String(v);
|
||||
const fmtAmount = () => {
|
||||
const cat = String(t.category || 'ECONOMIC').toUpperCase();
|
||||
const unit = cat === 'TIME' ? 'h' : cat === 'TRUST' ? 'trust' : 'ECO';
|
||||
return `${Number(t.amount || 0).toFixed(6)} ${unit}`;
|
||||
};
|
||||
const fmtDate = v => v ? new Date(v).toISOString().replace('T', ' ').slice(0, 19) + ' UTC' : '';
|
||||
const confirmedBy = Array.isArray(t.confirmedBy) ? t.confirmedBy : [];
|
||||
const required = t.from === t.to ? 1 : 2;
|
||||
const confirmedCount = confirmedBy.length;
|
||||
const tags = Array.isArray(t.tags) ? t.tags.join(', ') : '';
|
||||
|
||||
const sections = [];
|
||||
sections.push({ kind: 'title', text: `Concept: ${t.concept || '-'}` });
|
||||
sections.push({ kind: 'blank' });
|
||||
|
||||
sections.push({ kind: 'section', text: 'OASIS IDs' });
|
||||
sections.push({ kind: 'kv', label: 'From', value: fmt(t.from) });
|
||||
sections.push({ kind: 'kv', label: 'To', value: fmt(t.to) });
|
||||
sections.push({ kind: 'blank' });
|
||||
|
||||
sections.push({ kind: 'section', text: 'TERMS' });
|
||||
sections.push({ kind: 'kv', label: 'Category', value: String(t.category || 'ECONOMIC').toUpperCase() });
|
||||
if (String(t.category || '').toUpperCase() !== 'TRUST') sections.push({ kind: 'kv', label: 'Amount', value: fmtAmount() });
|
||||
sections.push({ kind: 'kv', label: 'Status', value: fmt(t.status) });
|
||||
if (t.deadline) sections.push({ kind: 'kv', label: 'Deadline', value: fmtDate(t.deadline) });
|
||||
if (tags) sections.push({ kind: 'kv', label: 'Tags', value: tags });
|
||||
sections.push({ kind: 'blank' });
|
||||
|
||||
sections.push({ kind: 'section', text: 'CONFIRMATIONS' });
|
||||
sections.push({ kind: 'kv', label: 'Required', value: String(required) });
|
||||
sections.push({ kind: 'kv', label: 'Confirmed', value: String(confirmedCount) });
|
||||
if (confirmedBy.length) {
|
||||
for (const f of confirmedBy) sections.push({ kind: 'kv', label: 'Signed by', value: fmt(f) });
|
||||
}
|
||||
sections.push({ kind: 'blank' });
|
||||
|
||||
sections.push({ kind: 'section', text: 'BLOCK VERIFICATION' });
|
||||
if (b && b.id) {
|
||||
sections.push({ kind: 'kv', label: 'Block ID', value: fmt(b.id) });
|
||||
if (b.ts) sections.push({ kind: 'kv', label: 'Block Timestamp', value: fmtDate(b.ts) });
|
||||
if (b.type) sections.push({ kind: 'kv', label: 'Block Type', value: String(b.type).toUpperCase() });
|
||||
if (b.author) sections.push({ kind: 'kv', label: 'Block Author', value: fmt(b.author) });
|
||||
if (b.size) sections.push({ kind: 'kv', label: 'Block Size', value: `${b.size} bytes` });
|
||||
} else {
|
||||
sections.push({ kind: 'kv', label: 'Block ID', value: fmt(t.id) });
|
||||
}
|
||||
sections.push({ kind: 'blank' });
|
||||
|
||||
sections.push({ kind: 'section', text: 'METADATA' });
|
||||
sections.push({ kind: 'kv', label: 'Transfer ID', value: fmt(t.id) });
|
||||
if (t.createdAt) sections.push({ kind: 'kv', label: 'Created At', value: fmtDate(t.createdAt) });
|
||||
if (t.updatedAt) sections.push({ kind: 'kv', label: 'Updated At', value: fmtDate(t.updatedAt) });
|
||||
|
||||
const lines = [];
|
||||
for (const s of sections) {
|
||||
if (s.kind === 'kv') {
|
||||
const txt = `${s.label}: ${s.value}`;
|
||||
for (const w of wrap(txt, 82)) lines.push({ kind: 'kv', text: w });
|
||||
} else {
|
||||
lines.push(s);
|
||||
}
|
||||
}
|
||||
|
||||
const maxBodyLines = Math.floor((bodyTop - bodyBottom) / lineH);
|
||||
const pages = [];
|
||||
for (let i = 0; i < lines.length; i += maxBodyLines) pages.push(lines.slice(i, i + maxBodyLines));
|
||||
if (!pages.length) pages.push([]);
|
||||
|
||||
const objects = [];
|
||||
const addObj = body => { objects.push(body); return objects.length; };
|
||||
|
||||
const catalogId = addObj(null);
|
||||
const pagesId = addObj(null);
|
||||
const fontId = addObj('<< /Type /Font /Subtype /Type1 /BaseFont /Courier >>');
|
||||
const fontBoldId = addObj('<< /Type /Font /Subtype /Type1 /BaseFont /Courier-Bold >>');
|
||||
let logoXObjId = null;
|
||||
if (logoBuf && logoDims) {
|
||||
const cs = logoDims.c === 1 ? '/DeviceGray' : '/DeviceRGB';
|
||||
const dict = `<< /Type /XObject /Subtype /Image /Width ${logoDims.w} /Height ${logoDims.h} /ColorSpace ${cs} /BitsPerComponent 8 /Filter /DCTDecode /Length ${logoBuf.length} >>`;
|
||||
logoXObjId = addObj({ dict, stream: logoBuf });
|
||||
}
|
||||
|
||||
const exportDate = new Date().toISOString().replace('T', ' ').slice(0, 19) + ' UTC';
|
||||
const footerLeft = `Generated: ${exportDate}`;
|
||||
|
||||
const pageIds = [];
|
||||
const contentIds = [];
|
||||
|
||||
pages.forEach((pg, pgIdx) => {
|
||||
const parts = [];
|
||||
if (logoXObjId) {
|
||||
const logoH = 60;
|
||||
const logoW = Math.round((logoDims.w / logoDims.h) * logoH);
|
||||
parts.push(`q\n${logoW} 0 0 ${logoH} ${marginX} ${pageH - headerH + 15} cm\n/Logo Do\nQ`);
|
||||
}
|
||||
const titleX = (logoXObjId ? marginX + 80 : marginX);
|
||||
const titleY = pageH - 45;
|
||||
parts.push(`BT\n/F2 16 Tf\n${titleX} ${titleY} Td\n(${escapePdf('OASIS - Smart Contract')}) Tj\nET`);
|
||||
const prefix = 'Issued to: ';
|
||||
const prefixW = prefix.length * 5.4;
|
||||
parts.push(`BT\n/F1 9 Tf\n${titleX} ${titleY - 16} Td\n(${escapePdf(prefix)}) Tj\nET`);
|
||||
parts.push(`BT\n/F2 9 Tf\n${titleX + prefixW} ${titleY - 16} Td\n(${escapePdf(String(viewerId || ''))}) Tj\nET`);
|
||||
|
||||
parts.push(`q\n0.6 0.6 0.6 RG\n0.5 w\n${marginX} ${pageH - headerH} m\n${pageW - marginX} ${pageH - headerH} l\nS\nQ`);
|
||||
|
||||
let y = bodyTop;
|
||||
for (const ln of pg) {
|
||||
if (ln.kind === 'title') {
|
||||
parts.push(`BT\n/F2 14 Tf\n0 0 0 rg\n${marginX} ${y} Td\n(${escapePdf(ln.text)}) Tj\nET`);
|
||||
} else if (ln.kind === 'subtitle') {
|
||||
parts.push(`BT\n/F1 11 Tf\n0.2 0.2 0.2 rg\n${marginX} ${y} Td\n(${escapePdf(ln.text)}) Tj\nET`);
|
||||
} else if (ln.kind === 'section') {
|
||||
parts.push(`BT\n/F2 11 Tf\n0 0 0 rg\n${marginX} ${y} Td\n(${escapePdf(ln.text)}) Tj\nET`);
|
||||
parts.push(`q\n0 0 0 RG\n0.5 w\n${marginX} ${y - 3} m\n${pageW - marginX} ${y - 3} l\nS\nQ`);
|
||||
} else if (ln.kind === 'kv') {
|
||||
parts.push(`BT\n/F1 10 Tf\n0 0 0 rg\n${marginX} ${y} Td\n(${escapePdf(ln.text)}) Tj\nET`);
|
||||
}
|
||||
y -= lineH;
|
||||
}
|
||||
|
||||
parts.push(`q\n0.6 0.6 0.6 RG\n0.5 w\n${marginX} ${footerH + 5} m\n${pageW - marginX} ${footerH + 5} l\nS\nQ`);
|
||||
parts.push(`BT\n/F1 8 Tf\n${marginX} ${footerH - 10} Td\n(${escapePdf(footerLeft)}) Tj\nET`);
|
||||
const pageLabel = `Page ${pgIdx + 1} of ${pages.length}`;
|
||||
const pageLabelW = pageLabel.length * 4.8;
|
||||
parts.push(`BT\n/F1 8 Tf\n${pageW - marginX - pageLabelW} ${footerH - 10} Td\n(${escapePdf(pageLabel)}) Tj\nET`);
|
||||
|
||||
const content = parts.join('\n');
|
||||
const stream = `<< /Length ${Buffer.byteLength(content)} >>\nstream\n${content}\nendstream`;
|
||||
const cid = addObj(stream);
|
||||
contentIds.push(cid);
|
||||
const pid = addObj(null);
|
||||
pageIds.push(pid);
|
||||
});
|
||||
|
||||
for (let i = 0; i < pageIds.length; i++) {
|
||||
const resources = logoXObjId
|
||||
? `<< /Font << /F1 ${fontId} 0 R /F2 ${fontBoldId} 0 R >> /XObject << /Logo ${logoXObjId} 0 R >> >>`
|
||||
: `<< /Font << /F1 ${fontId} 0 R /F2 ${fontBoldId} 0 R >> >>`;
|
||||
objects[pageIds[i] - 1] = `<< /Type /Page /Parent ${pagesId} 0 R /MediaBox [0 0 ${pageW} ${pageH}] /Contents ${contentIds[i]} 0 R /Resources ${resources} >>`;
|
||||
}
|
||||
objects[catalogId - 1] = `<< /Type /Catalog /Pages ${pagesId} 0 R >>`;
|
||||
objects[pagesId - 1] = `<< /Type /Pages /Kids [${pageIds.map(id => `${id} 0 R`).join(' ')}] /Count ${pageIds.length} >>`;
|
||||
|
||||
const chunks = [];
|
||||
const offsets = [0];
|
||||
let byteLen = 0;
|
||||
const push = (buf) => { chunks.push(buf); byteLen += buf.length; };
|
||||
push(Buffer.from('%PDF-1.4\n%\xE2\xE3\xCF\xD3\n', 'binary'));
|
||||
for (let i = 0; i < objects.length; i++) {
|
||||
offsets.push(byteLen);
|
||||
const obj = objects[i];
|
||||
if (obj && typeof obj === 'object' && obj.dict && obj.stream) {
|
||||
push(Buffer.from(`${i + 1} 0 obj\n${obj.dict}\nstream\n`, 'binary'));
|
||||
push(obj.stream);
|
||||
push(Buffer.from('\nendstream\nendobj\n', 'binary'));
|
||||
} else {
|
||||
push(Buffer.from(`${i + 1} 0 obj\n${obj}\nendobj\n`, 'binary'));
|
||||
}
|
||||
}
|
||||
const xrefStart = byteLen;
|
||||
let xref = `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`;
|
||||
for (let i = 1; i <= objects.length; i++) xref += `${String(offsets[i]).padStart(10, '0')} 00000 n \n`;
|
||||
xref += `trailer\n<< /Size ${objects.length + 1} /Root ${catalogId} 0 R >>\nstartxref\n${xrefStart}\n%%EOF`;
|
||||
push(Buffer.from(xref, 'binary'));
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
|
||||
module.exports = { buildSmartContractPdf };
|
||||
|
|
@ -1,50 +1,12 @@
|
|||
const fetch = require('../server/node_modules/node-fetch');
|
||||
const { existsSync, readFileSync, writeFileSync, unlinkSync } = require('fs');
|
||||
const { join } = require('path');
|
||||
|
||||
const localpackage = join(__dirname, '../server/package.json');
|
||||
const remoteUrl = 'https://code.03c8.net/KrakensLab/oasis/raw/master/src/server/package.json';
|
||||
const remoteUrl2 = 'https://raw.githubusercontent.com/epsylon/oasis/refs/heads/main/src/server/package.json';
|
||||
const remoteUrl = 'https://code.03c8.net/KrakensLab/oasis/raw/master/src/server/package.json'; // Official SNH-Oasis
|
||||
const remoteUrl2 = 'https://raw.githubusercontent.com/epsylon/oasis/refs/heads/main/src/server/package.json'; // Mirror SNH-Oasis
|
||||
|
||||
let printed = false;
|
||||
const repoRoot = join(__dirname, '..', '..');
|
||||
const updateFlagFile = join(__dirname, '../server/.update_required');
|
||||
|
||||
const git = (args, cwd = repoRoot) => new Promise((resolve) => {
|
||||
require('child_process').execFile('git', args, { cwd, timeout: 60000, env: { ...process.env, GIT_TERMINAL_PROMPT: '0' } }, (err, out) => resolve(err ? '' : String(out || '').trim()));
|
||||
});
|
||||
const commitOf = (line) => {
|
||||
const [hash, date, ...rest] = String(line || '').split('\t');
|
||||
return hash ? { hash, date: date || '', subject: rest.join('\t') } : null;
|
||||
};
|
||||
const cleanOrigin = (url) => String(url || '').replace(/\/\/[^@\/]*@/, '//').replace(/\.git$/, '');
|
||||
|
||||
async function collectDetails(root = repoRoot) {
|
||||
if (!existsSync(join(root, '.git'))) return null;
|
||||
const installed = commitOf(await git(['log', '-1', '--format=%h%x09%cI%x09%s'], root));
|
||||
await git(['fetch', '--quiet'], root);
|
||||
const upstream = (await git(['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'], root)) || 'origin/master';
|
||||
const available = commitOf(await git(['log', '-1', '--format=%h%x09%cI%x09%s', upstream], root));
|
||||
const commits = (await git(['log', '--format=%h%x09%cI%x09%s', '-n', '10', `HEAD..${upstream}`], root)).split('\n').map(commitOf).filter(Boolean);
|
||||
const count = Number(await git(['rev-list', '--count', `HEAD..${upstream}`], root)) || commits.length;
|
||||
const stat = await git(['diff', '--shortstat', 'HEAD', upstream], root);
|
||||
const num = (re) => { const m = stat.match(re); return m ? Number(m[1]) : 0; };
|
||||
return {
|
||||
installed, available, commits, count,
|
||||
files: num(/(\d+) files? changed/), insertions: num(/(\d+) insertions?/), deletions: num(/(\d+) deletions?/),
|
||||
origin: cleanOrigin(await git(['remote', 'get-url', 'origin'], root))
|
||||
};
|
||||
}
|
||||
exports.collectDetails = collectDetails;
|
||||
|
||||
exports.readUpdateInfo = () => {
|
||||
if (!existsSync(updateFlagFile)) return null;
|
||||
try {
|
||||
const info = JSON.parse(readFileSync(updateFlagFile, 'utf8'));
|
||||
return info && info.required ? info : { required: true };
|
||||
} catch (_) {
|
||||
return { required: true };
|
||||
}
|
||||
};
|
||||
|
||||
async function extractVersionFromText(text) {
|
||||
try {
|
||||
|
|
@ -71,22 +33,11 @@ async function diffVersion(body, callback) {
|
|||
const updateFlagPath = join(__dirname, "../server/.update_required");
|
||||
|
||||
if (remoteVersion !== localVersion) {
|
||||
const alreadyKnown = existsSync(updateFlagPath);
|
||||
writeFileSync(updateFlagPath, JSON.stringify({ required: true, version: remoteVersion, installedVersion: localVersion }));
|
||||
collectDetails().then((details) => {
|
||||
if (details && existsSync(updateFlagPath)) writeFileSync(updateFlagPath, JSON.stringify({ required: true, version: remoteVersion, installedVersion: localVersion, details }));
|
||||
}).catch(() => {});
|
||||
if (!alreadyKnown) {
|
||||
try {
|
||||
const { notify, i18nNow } = require('./desktopNotify');
|
||||
const i18n = i18nNow();
|
||||
notify(i18n.notifyUpdateLabel, `${i18n.updateBannerText} (${remoteVersion})`);
|
||||
} catch (_) {}
|
||||
}
|
||||
writeFileSync(updateFlagPath, JSON.stringify({ required: true }));
|
||||
callback("required");
|
||||
} else {
|
||||
if (existsSync(updateFlagPath)) unlinkSync(updateFlagPath);
|
||||
callback("");
|
||||
callback(""); // no updates required
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error comparing versions:", error.message);
|
||||
|
|
@ -98,8 +49,15 @@ async function checkMirror(callback) {
|
|||
try {
|
||||
const response = await fetch(remoteUrl2, {
|
||||
method: 'GET',
|
||||
headers: { 'Accept': 'application/json, text/plain, */*' },
|
||||
signal: AbortSignal.timeout(20000)
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
|
||||
'Accept': 'application/json, text/plain, */*',
|
||||
'Accept-Language': 'en-US,en;q=0.9',
|
||||
'Accept-Encoding': 'gzip, deflate, br',
|
||||
'Connection': 'keep-alive',
|
||||
'Referer': 'https://raw.githubusercontent.com',
|
||||
'Origin': 'https://raw.githubusercontent.com'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
|
|
@ -114,154 +72,20 @@ async function checkMirror(callback) {
|
|||
}
|
||||
}
|
||||
|
||||
const networkAllowed = () => {
|
||||
if (process.argv.includes('--offline') || process.env.OASIS_NETWORK_PAUSED === '1') return false;
|
||||
try { if (require('../server/ssb_config').offline === true) return false; } catch (_) {}
|
||||
try { if (require('../configs/config-manager.js').getConfig().networkPaused === true) return false; } catch (_) {}
|
||||
return true;
|
||||
};
|
||||
|
||||
const MOBILE_RELEASE_SOURCES = [
|
||||
{ url: 'https://code.03c8.net/KrakensLab/oasis/releases', type: 'html' },
|
||||
{ url: 'https://api.github.com/repos/epsylon/oasis/releases?per_page=20', type: 'json' }
|
||||
];
|
||||
const MOBILE_APK_RE = /^oasis-v(\d+\.\d+\.\d+)\.apk$/i;
|
||||
const MOBILE_APK_HTML_RE = /oasis-v(\d+\.\d+\.\d+)\.apk/i;
|
||||
const ECOIN_RELEASES_API = 'https://api.github.com/repos/epsylon/ecoin/releases?per_page=20';
|
||||
const ECOIN_RELEASES_PAGE = 'https://github.com/epsylon/ecoin/releases';
|
||||
const ECOIN_APK_RE = /^ecoin-wallet-v(\d+(?:\.\d+)*)\.apk$/i;
|
||||
const MOBILE_FETCH_HEADERS = {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
|
||||
'Accept-Language': 'en-US,en;q=0.9'
|
||||
};
|
||||
|
||||
let ecoinWalletApk = '';
|
||||
|
||||
const versionParts = (v) => String(v || '').split('.').map(n => parseInt(n, 10) || 0);
|
||||
|
||||
const isNewerVersion = (remote, local) => {
|
||||
const a = versionParts(remote), b = versionParts(local);
|
||||
const len = Math.max(a.length, b.length, 3);
|
||||
for (let i = 0; i < len; i++) {
|
||||
if ((a[i] || 0) !== (b[i] || 0)) return (a[i] || 0) > (b[i] || 0);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const localMobileVersion = () => {
|
||||
const fromEnv = String(process.env.OASIS_ANDROID_VERSION || '').split('-')[0].trim();
|
||||
if (fromEnv) return fromEnv;
|
||||
try {
|
||||
return JSON.parse(readFileSync(localpackage, 'utf8')).version || '';
|
||||
} catch (error) {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const pickReleaseFromJson = (releases) => {
|
||||
for (const rel of Array.isArray(releases) ? releases : []) {
|
||||
if (!rel || rel.draft) continue;
|
||||
for (const asset of Array.isArray(rel.assets) ? rel.assets : []) {
|
||||
const match = MOBILE_APK_RE.exec(String((asset && asset.name) || ''));
|
||||
if (match) {
|
||||
return {
|
||||
version: match[1],
|
||||
page: String(rel.html_url || ''),
|
||||
apk: String(asset.browser_download_url || '')
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const pickReleaseFromHtml = (html, pageUrl) => {
|
||||
const match = MOBILE_APK_HTML_RE.exec(String(html || ''));
|
||||
return match ? { version: match[1], page: pageUrl, apk: '' } : null;
|
||||
};
|
||||
|
||||
const fetchMobileRelease = async (source) => {
|
||||
const isHtml = source.type === 'html';
|
||||
const response = await fetch(source.url, {
|
||||
method: 'GET',
|
||||
headers: { ...MOBILE_FETCH_HEADERS, 'Accept': isHtml ? 'text/html,application/xhtml+xml' : 'application/json, text/plain, */*' }
|
||||
});
|
||||
if (!response.ok) throw new Error(`Request failed with status ${response.status}`);
|
||||
return isHtml
|
||||
? pickReleaseFromHtml(await response.text(), source.url)
|
||||
: pickReleaseFromJson(await response.json());
|
||||
};
|
||||
|
||||
const refreshEcoinWalletApk = async () => {
|
||||
try {
|
||||
const response = await fetch(ECOIN_RELEASES_API, {
|
||||
method: 'GET',
|
||||
headers: { ...MOBILE_FETCH_HEADERS, 'Accept': 'application/json, text/plain, */*' }
|
||||
});
|
||||
if (!response.ok) return;
|
||||
const releases = await response.json();
|
||||
let best = null;
|
||||
for (const rel of Array.isArray(releases) ? releases : []) {
|
||||
if (!rel || rel.draft) continue;
|
||||
for (const asset of Array.isArray(rel.assets) ? rel.assets : []) {
|
||||
const match = ECOIN_APK_RE.exec(String((asset && asset.name) || ''));
|
||||
if (!match || !asset.browser_download_url) continue;
|
||||
if (!best || isNewerVersion(match[1], best.version)) best = { version: match[1], apk: String(asset.browser_download_url) };
|
||||
}
|
||||
}
|
||||
if (best) ecoinWalletApk = best.apk;
|
||||
} catch (error) {}
|
||||
};
|
||||
|
||||
exports.ecoinWalletDownloadUrl = () => {
|
||||
if (String(process.env.OASIS_ANDROID_INSTALLER || '') === 'com.android.vending') return ECOIN_RELEASES_PAGE;
|
||||
if (!ecoinWalletApk) refreshEcoinWalletApk();
|
||||
return ecoinWalletApk || ECOIN_RELEASES_PAGE;
|
||||
};
|
||||
|
||||
exports.getMobileRemoteVersion = async () => {
|
||||
await refreshEcoinWalletApk();
|
||||
if (String(process.env.OASIS_ANDROID_INSTALLER || '') === 'com.android.vending') return;
|
||||
const localVersion = localMobileVersion();
|
||||
if (!localVersion) return;
|
||||
const updateFlagPath = join(__dirname, '../server/.update_required');
|
||||
const found = [];
|
||||
for (const source of MOBILE_RELEASE_SOURCES) {
|
||||
try {
|
||||
const candidate = await fetchMobileRelease(source);
|
||||
if (candidate) found.push(candidate);
|
||||
} catch (error) {}
|
||||
}
|
||||
if (!found.length) return;
|
||||
const top = found.reduce((best, r) => (isNewerVersion(r.version, best.version) ? r : best), found[0]);
|
||||
const sameVersion = found.filter(r => r.version === top.version);
|
||||
const withApk = sameVersion.find(r => r.apk);
|
||||
const release = { version: top.version, page: sameVersion[0].page, apk: withApk ? withApk.apk : '' };
|
||||
if (isNewerVersion(release.version, localVersion)) {
|
||||
try {
|
||||
writeFileSync(updateFlagPath, JSON.stringify({
|
||||
required: true,
|
||||
version: release.version,
|
||||
installedVersion: localVersion,
|
||||
page: release.page || release.apk,
|
||||
apk: release.apk
|
||||
}));
|
||||
} catch (error) {}
|
||||
console.log(`\noasis@version: Oasis ${release.version} is available at ${release.apk || release.page}\n`);
|
||||
} else if (existsSync(updateFlagPath)) {
|
||||
try { unlinkSync(updateFlagPath); } catch (error) {}
|
||||
}
|
||||
};
|
||||
|
||||
exports.getRemoteVersion = async () => {
|
||||
if (process.env.OASIS_MOBILE === '1') return exports.getMobileRemoteVersion();
|
||||
if (!networkAllowed()) return;
|
||||
if (existsSync(join(__dirname, '..', '..', '.git'))) {
|
||||
if (existsSync('../../.git')) {
|
||||
try {
|
||||
const response = await fetch(remoteUrl, {
|
||||
method: 'GET',
|
||||
headers: { 'Accept': 'application/json, text/plain, */*' },
|
||||
signal: AbortSignal.timeout(20000)
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
|
||||
'Accept': 'application/json, text/plain, */*',
|
||||
'Accept-Language': 'en-US,en;q=0.9',
|
||||
'Accept-Encoding': 'gzip, deflate, br',
|
||||
'Connection': 'keep-alive',
|
||||
'Referer': 'https://code.03c8.net',
|
||||
'Origin': 'https://code.03c8.net'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
|
|
|
|||
|
|
@ -1,119 +1,116 @@
|
|||
const INSTITUTIONAL_VOTE_TAG = /^(gov|govMethod|courtsCase|courtsMethod):/;
|
||||
|
||||
const msOf = (v) => {
|
||||
const t = Date.parse(v);
|
||||
return Number.isFinite(t) ? t : null;
|
||||
};
|
||||
|
||||
const isLater = (a, b) => a.ts > b.ts || (a.ts === b.ts && a.seq > b.seq);
|
||||
|
||||
const legacyChoice = (kid, parent, options) => {
|
||||
const kVoters = Array.isArray(kid.content.voters) ? kid.content.voters : [];
|
||||
const pVoters = Array.isArray(parent.content.voters) ? parent.content.voters : [];
|
||||
if (!kVoters.includes(kid.author) || pVoters.includes(kid.author)) return null;
|
||||
const kVotes = kid.content.votes || {};
|
||||
const pVotes = parent.content.votes || {};
|
||||
let choice = null;
|
||||
for (const o of options) {
|
||||
const d = (Number(kVotes[o]) || 0) - (Number(pVotes[o]) || 0);
|
||||
if (d === 0) continue;
|
||||
if (d !== 1 || choice !== null) return null;
|
||||
choice = o;
|
||||
}
|
||||
return choice;
|
||||
};
|
||||
|
||||
const buildVoteResults = (messages) => {
|
||||
const buildVoteTally = (messages) => {
|
||||
const nodes = new Map();
|
||||
const ballotsByTarget = new Map();
|
||||
const ballots = [];
|
||||
|
||||
for (const m of messages || []) {
|
||||
for (const m of messages) {
|
||||
const v = m && m.value;
|
||||
const c = v && v.content;
|
||||
if (!c) continue;
|
||||
const ts = Number(v.timestamp || m.timestamp || 0) || 0;
|
||||
const seq = Number(v.sequence || 0) || 0;
|
||||
if (c.type === 'votesVote') {
|
||||
if (typeof c.target !== 'string' || !v.author) continue;
|
||||
if (!ballotsByTarget.has(c.target)) ballotsByTarget.set(c.target, []);
|
||||
ballotsByTarget.get(c.target).push({ author: v.author, choice: c.choice, ts, seq });
|
||||
continue;
|
||||
}
|
||||
if (c.type === 'votesVote') { if (c.target) ballots.push({ target: c.target, author: v.author, choice: c.choice }); continue; }
|
||||
if (c.type !== 'votes') continue;
|
||||
nodes.set(m.key, { key: m.key, author: v.author, content: c, ts, seq });
|
||||
nodes.set(m.key, { key: m.key, author: v.author, content: c, ts: v.timestamp || m.timestamp || 0 });
|
||||
}
|
||||
|
||||
const prev = new Map();
|
||||
for (const [k, n] of nodes) {
|
||||
const t = n.content.replaces;
|
||||
if (typeof t === 'string' && t !== k && nodes.has(t)) prev.set(k, t);
|
||||
if (typeof t === 'string' && nodes.has(t)) prev.set(k, t);
|
||||
}
|
||||
const childrenOf = new Map();
|
||||
for (const [k, p] of prev) {
|
||||
if (!childrenOf.has(p)) childrenOf.set(p, []);
|
||||
childrenOf.get(p).push(k);
|
||||
}
|
||||
const rootOf = (k) => { let x = k, g = 0; while (prev.has(x) && g++ < 100000) x = prev.get(x); return x; };
|
||||
|
||||
const groups = new Map();
|
||||
for (const k of nodes.keys()) { const r = rootOf(k); if (!groups.has(r)) groups.set(r, []); groups.get(r).push(k); }
|
||||
|
||||
const results = new Map();
|
||||
const tallyByKey = new Map();
|
||||
for (const [root, keys] of groups.entries()) {
|
||||
const rootNode = nodes.get(root);
|
||||
if (!rootNode) continue;
|
||||
const creator = rootNode.author;
|
||||
const tags = Array.isArray(rootNode.content.tags) ? rootNode.content.tags : [];
|
||||
const frozen = tags.some(t => INSTITUTIONAL_VOTE_TAG.test(String(t)));
|
||||
let current = rootNode;
|
||||
if (!frozen) {
|
||||
for (const k of keys) {
|
||||
const n = nodes.get(k);
|
||||
if (n.author === creator && isLater(n, current)) current = n;
|
||||
const options = Array.isArray(rootNode.content.options) ? rootNode.content.options : [];
|
||||
let votesMap = Object.assign({}, rootNode.content.votes || {});
|
||||
let voters = Array.isArray(rootNode.content.voters) ? rootNode.content.voters.slice() : [];
|
||||
for (const o of options) if (!(o in votesMap)) votesMap[o] = 0;
|
||||
|
||||
let cur = root;
|
||||
let g = 0;
|
||||
let creatorChoice = null;
|
||||
let creatorCounted = false;
|
||||
while (g++ < 100000) {
|
||||
const kids = (childrenOf.get(cur) || []).map(k => nodes.get(k)).filter(Boolean).sort((a, b) => a.ts - b.ts);
|
||||
if (!kids.length) break;
|
||||
let advanced = false;
|
||||
for (const kid of kids) {
|
||||
const kVotes = Object.assign({}, kid.content.votes || {});
|
||||
const kVoters = Array.isArray(kid.content.voters) ? kid.content.voters.slice() : [];
|
||||
for (const o of options) if (!(o in kVotes)) kVotes[o] = 0;
|
||||
if (kid.author === rootNode.author) {
|
||||
if (!creatorCounted && kVoters.includes(rootNode.author) && !voters.includes(rootNode.author)) {
|
||||
creatorCounted = true;
|
||||
let inc = null;
|
||||
let incCount = 0;
|
||||
for (const o of options) {
|
||||
const d = (Number(kVotes[o]) || 0) - (Number(votesMap[o]) || 0);
|
||||
if (d >= 1) { inc = o; incCount++; }
|
||||
}
|
||||
if (incCount === 1) creatorChoice = inc;
|
||||
}
|
||||
votesMap = kVotes;
|
||||
voters = kVoters;
|
||||
cur = kid.key;
|
||||
advanced = true;
|
||||
break;
|
||||
}
|
||||
if (voters.includes(kid.author)) continue;
|
||||
if (kVoters.length !== voters.length + 1) continue;
|
||||
const added = kVoters.filter(x => !voters.includes(x));
|
||||
if (added.length !== 1 || added[0] !== kid.author) continue;
|
||||
let deltas = 0;
|
||||
let deltaOk = true;
|
||||
for (const o of options) {
|
||||
const d = (Number(kVotes[o]) || 0) - (Number(votesMap[o]) || 0);
|
||||
if (d === 0) continue;
|
||||
if (d === 1) { deltas++; continue; }
|
||||
deltaOk = false;
|
||||
break;
|
||||
}
|
||||
if (!deltaOk || deltas !== 1) continue;
|
||||
votesMap = kVotes;
|
||||
voters = kVoters;
|
||||
cur = kid.key;
|
||||
advanced = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const options = Array.isArray(current.content.options) ? current.content.options.filter(o => typeof o === 'string') : [];
|
||||
const deadline = typeof current.content.deadline === 'string' ? current.content.deadline : null;
|
||||
const opensAt = rootNode.ts;
|
||||
const closesAt = deadline ? msOf(deadline) : null;
|
||||
|
||||
const cast = [];
|
||||
for (const k of keys) {
|
||||
for (const b of ballotsByTarget.get(k) || []) cast.push(b);
|
||||
const n = nodes.get(k);
|
||||
if (n.author === creator || !prev.has(k)) continue;
|
||||
const choice = legacyChoice(n, nodes.get(prev.get(k)), options);
|
||||
if (choice !== null) cast.push({ author: n.author, choice, ts: n.ts, seq: n.seq });
|
||||
if (!advanced) break;
|
||||
}
|
||||
|
||||
const latest = new Map();
|
||||
for (const b of cast) {
|
||||
if (!b.author || b.author === creator) continue;
|
||||
if (voters.includes(rootNode.author)) {
|
||||
voters = voters.filter(x => x !== rootNode.author);
|
||||
if (creatorChoice && (Number(votesMap[creatorChoice]) || 0) > 0) votesMap[creatorChoice] = votesMap[creatorChoice] - 1;
|
||||
}
|
||||
|
||||
const voterSet = new Set(voters);
|
||||
let totalVotes = voters.length;
|
||||
const chainKeys = new Set(keys);
|
||||
for (const b of ballots) {
|
||||
if (!chainKeys.has(b.target)) continue;
|
||||
if (b.author === rootNode.author) continue;
|
||||
if (!b.author || voterSet.has(b.author)) continue;
|
||||
if (!options.includes(b.choice)) continue;
|
||||
if (b.ts < opensAt) continue;
|
||||
if (closesAt !== null && b.ts > closesAt) continue;
|
||||
const known = latest.get(b.author);
|
||||
if (!known || isLater(b, known)) latest.set(b.author, b);
|
||||
}
|
||||
|
||||
const votes = {};
|
||||
for (const o of options) votes[o] = 0;
|
||||
const voters = [];
|
||||
for (const b of [...latest.values()].sort((a, b) => a.ts - b.ts || a.seq - b.seq)) {
|
||||
votes[b.choice] = (votes[b.choice] || 0) + 1;
|
||||
voterSet.add(b.author);
|
||||
voters.push(b.author);
|
||||
votesMap[b.choice] = (votesMap[b.choice] || 0) + 1;
|
||||
totalVotes += 1;
|
||||
}
|
||||
|
||||
const result = { votes, voters, totalVotes: voters.length, creator, rootId: root, deadline, options };
|
||||
for (const k of keys) results.set(k, result);
|
||||
const tally = { votes: votesMap, voters, totalVotes };
|
||||
for (const k of keys) tallyByKey.set(k, tally);
|
||||
}
|
||||
|
||||
return results;
|
||||
};
|
||||
|
||||
const buildVoteTally = (messages) => {
|
||||
const shared = new Map();
|
||||
const tallyByKey = new Map();
|
||||
for (const [k, r] of buildVoteResults(messages)) {
|
||||
if (!shared.has(r)) shared.set(r, { votes: r.votes, voters: r.voters, totalVotes: r.totalVotes });
|
||||
tallyByKey.set(k, shared.get(r));
|
||||
}
|
||||
return tallyByKey;
|
||||
};
|
||||
|
||||
module.exports = { buildVoteTally, buildVoteResults };
|
||||
module.exports = { buildVoteTally };
|
||||
|
|
|
|||
17
src/backend/wallet_addresses.js
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const STORAGE_DIR = path.join(__dirname, "..", "configs");
|
||||
const ADDR_PATH = path.join(STORAGE_DIR, "wallet-addresses.json");
|
||||
|
||||
function ensure() {
|
||||
if (!fs.existsSync(STORAGE_DIR)) fs.mkdirSync(STORAGE_DIR, { recursive: true });
|
||||
if (!fs.existsSync(ADDR_PATH)) fs.writeFileSync(ADDR_PATH, "{}");
|
||||
}
|
||||
function readAll() { ensure(); return JSON.parse(fs.readFileSync(ADDR_PATH, "utf8")); }
|
||||
function writeAll(m) { fs.writeFileSync(ADDR_PATH, JSON.stringify(m, null, 2)); }
|
||||
|
||||
async function getAddress(userId) { const m = readAll(); return m[userId] || null; }
|
||||
async function setAddress(userId, address) { const m = readAll(); m[userId] = address; writeAll(m); return true; }
|
||||
|
||||
module.exports = { getAddress, setAddress };
|
||||
|
|
@ -1,93 +0,0 @@
|
|||
Copyright 2020 The Anton Project Authors (https://github.com/googlefonts/AntonFont.git)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
|
Before Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 2 KiB |
|
Before Width: | Height: | Size: 2 KiB |
|
Before Width: | Height: | Size: 2 KiB |
|
Before Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 3.2 KiB |
|
|
@ -1 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#000" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M3 13l3-8h12l3 8v6a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1z"/><path d="M3 13h5l1 3h6l1-3h5"/></svg>
|
||||
|
Before Width: | Height: | Size: 251 B |
|
Before Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 12 KiB |
25
src/client/assets/images/world-map.svg
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
src/client/assets/images/worldmap-z2.png
Normal file
|
After Width: | Height: | Size: 132 KiB |
|
|
@ -1,507 +0,0 @@
|
|||
/* Lanzador, barras, tarjetas e identidades con el tema Acampada (rama movil).
|
||||
*
|
||||
* OasisMobile.css (cargado antes que themes/Acampada.css) esta pensado para fondo
|
||||
* oscuro; esta hoja va detras del tema, solo con Acampada, y cambia lo que alli
|
||||
* es amarillo sobre negro por negro sobre blanco con el cian neon de
|
||||
* acampada26s.net. Las variables --a26-* las define themes/Acampada.css. */
|
||||
|
||||
body { background: var(--a26-bg) !important; }
|
||||
|
||||
/* ---------- barra de arriba ---------- */
|
||||
.oasis-mobile-topbar {
|
||||
background: var(--a26-bg) !important;
|
||||
border: 0 !important;
|
||||
border-bottom: var(--a26-bd) !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
.oasis-mobile-topbar .omt-menus { background: transparent !important; border: 0 !important; box-shadow: none !important; gap: 10px; }
|
||||
.oasis-mobile-topbar .omt-menus .oasis-menu-btn {
|
||||
font-family: var(--a26-tit) !important;
|
||||
font-weight: 400 !important;
|
||||
text-transform: uppercase;
|
||||
font-size: 20px !important;
|
||||
letter-spacing: .02em;
|
||||
min-height: 44px !important;
|
||||
padding: 4px 16px !important;
|
||||
background: var(--a26-bg) !important;
|
||||
color: var(--a26-fg) !important;
|
||||
border: var(--a26-bd) !important;
|
||||
border-radius: var(--a26-radius) !important;
|
||||
box-shadow: 0 4px 0 var(--a26-fg) !important;
|
||||
}
|
||||
.oasis-mobile-topbar .omt-menus .oasis-menu-btn span { color: var(--a26-fg) !important; }
|
||||
.oasis-mobile-topbar .omt-menus .oasis-menu-btn.active { background: var(--a26-neon) !important; }
|
||||
.oasis-mobile-topbar .omt-menus .oasis-menu-btn:active { transform: translateY(3px); box-shadow: 0 1px 0 var(--a26-fg) !important; }
|
||||
.oasis-mobile-topbar .omt-logo img, .oasis-mobile-topbar .omt-avatar img {
|
||||
border: var(--a26-bd) !important;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
/* la cabecera de escritorio de upstream no pinta nada en el movil */
|
||||
.header { background: transparent !important; border: 0 !important; box-shadow: none !important; padding: 0 !important; }
|
||||
.header .top-bar-left, .header .top-bar-right { display: none !important; }
|
||||
|
||||
/* ---------- lanzador de modulos (views/hive_views.js) ---------- */
|
||||
.launcher, .ln-group, .ln-grid { background: transparent !important; border: 0 !important; box-shadow: none !important; }
|
||||
.header .launcher { margin: 18px auto 10px !important; }
|
||||
.ln-group + .ln-group { margin-top: 22px !important; }
|
||||
.ln-title {
|
||||
font-family: var(--a26-tit) !important;
|
||||
font-weight: 400 !important;
|
||||
font-size: 17px !important;
|
||||
letter-spacing: .03em !important;
|
||||
color: var(--a26-fg) !important;
|
||||
border-bottom: var(--a26-bd) !important;
|
||||
padding: 0 0 4px !important;
|
||||
margin: 0 2px 14px !important;
|
||||
}
|
||||
.ln-grid { gap: 16px 10px !important; }
|
||||
.ln-ico {
|
||||
width: 58px !important; height: 58px !important;
|
||||
background: var(--a26-bg) !important;
|
||||
color: var(--a26-fg) !important;
|
||||
border: var(--a26-bd) !important;
|
||||
border-radius: var(--a26-radius) !important;
|
||||
box-shadow: 0 4px 0 var(--a26-fg) !important;
|
||||
font-size: 24px !important;
|
||||
transition: transform .12s ease-out, box-shadow .12s ease-out;
|
||||
}
|
||||
.ln-app:active .ln-ico, .ln-app:focus-visible .ln-ico {
|
||||
transform: translateY(3px);
|
||||
box-shadow: 0 1px 0 var(--a26-fg) !important;
|
||||
background: var(--a26-neon) !important;
|
||||
border-color: var(--a26-fg) !important;
|
||||
}
|
||||
.ln-app:hover, .ln-app:focus-visible { background: transparent !important; }
|
||||
.ln-lbl { color: var(--a26-fg) !important; font-size: 12px !important; font-weight: 600 !important; }
|
||||
|
||||
/* ---------- barra de abajo ---------- */
|
||||
.oasis-bottombar {
|
||||
background: var(--a26-bg) !important;
|
||||
border: 0 !important;
|
||||
border-top: var(--a26-bd) !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
.oasis-bottombar { padding: 6px 4px calc(6px + env(safe-area-inset-bottom, 0px)) !important; gap: 2px !important; }
|
||||
.oasis-bottombar .bb-item { gap: 2px !important; }
|
||||
.oasis-bottombar .bb-ico { color: var(--a26-fg) !important; font-size: 21px !important; }
|
||||
.oasis-bottombar .bb-lbl {
|
||||
color: var(--a26-fg) !important;
|
||||
font-size: 10.5px !important;
|
||||
font-weight: 600;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.oasis-bottombar a.bb-item.current .bb-ico { background: var(--a26-neon) !important; color: var(--a26-fg) !important; }
|
||||
.oasis-bottombar a.bb-item.current .bb-lbl { color: var(--a26-fg) !important; }
|
||||
.oasis-bottombar .bb-badge { background: var(--a26-neon) !important; color: var(--a26-fg) !important; border: 2px solid var(--a26-fg) !important; }
|
||||
/* Nodos e Invitaciones: juntos y estrechos {
|
||||
background: var(--a26-bg) !important;
|
||||
border: var(--a26-bd-fino) !important;
|
||||
border-radius: var(--a26-radius) !important;
|
||||
padding: 2px !important;
|
||||
gap: 0 !important;
|
||||
}
|
||||
|
||||
/* ---------- personalizar la barra ---------- */
|
||||
.bb-manage-btn, .bb-pick-btn {
|
||||
background: var(--a26-bg) !important;
|
||||
color: var(--a26-fg) !important;
|
||||
border: var(--a26-bd) !important;
|
||||
border-radius: var(--a26-radius) !important;
|
||||
box-shadow: 0 4px 0 var(--a26-fg) !important;
|
||||
font-family: var(--a26-txt) !important;
|
||||
text-transform: none;
|
||||
}
|
||||
.bb-manage-ico, .bb-manage-x, .bb-pick-ico { color: var(--a26-fg) !important; }
|
||||
.bb-manage-lbl, .bb-pick-lbl { color: var(--a26-fg) !important; font-weight: 600; }
|
||||
.bb-manage h3, .bb-manage-add h3 { color: var(--a26-fg) !important; }
|
||||
.bb-manage-empty, .bb-manage-full { color: var(--a26-muted) !important; }
|
||||
|
||||
/* ---------- menu desplegable de upstream (si se abre) ---------- */
|
||||
.oasis-nav-header { background: var(--a26-bg) !important; color: var(--a26-fg) !important; border: var(--a26-bd-fino) !important; }
|
||||
.oasis-nav-list li a, .oasis-nav-list li a .emoji { color: var(--a26-fg) !important; }
|
||||
.oasis-nav-list li a.current { background: var(--a26-neon) !important; }
|
||||
|
||||
/* ---------- foro y chat: los colores de hive.css pensados para fondo negro ---------- */
|
||||
.forum-comment.level-1, .forum-comment.level-2, .forum-comment.level-3, .forum-comment.level-4 { border-left: 3px solid var(--a26-fg) !important; }
|
||||
.forum-comment.highlighted-reply { background: #e5fcff !important; border-left: 5px solid var(--a26-neon) !important; }
|
||||
.chat-message-form .filter-btn::before, .forum-send-btn::before { color: var(--a26-fg) !important; }
|
||||
|
||||
/* ---------- lo que mobile.css de upstream pinta en amarillo con !important ---------- */
|
||||
/* mobile.css va detras del tema, asi que estas reglas tienen que ir aqui */
|
||||
.create-button, button.create-button, a.create-button {
|
||||
background-color: var(--a26-fg) !important;
|
||||
border-color: var(--a26-fg) !important;
|
||||
color: var(--a26-bg) !important;
|
||||
}
|
||||
.filter-btn.active { background-color: var(--a26-neon) !important; border-color: var(--a26-fg) !important; color: var(--a26-fg) !important; }
|
||||
.peers-conn-actions button, .filter-box__button { background-color: var(--a26-bg) !important; color: var(--a26-fg) !important; }
|
||||
.oasis-id-copy { background: var(--a26-soft) !important; }
|
||||
.gov-overview tbody tr { border: var(--a26-bd-fino) !important; }
|
||||
button, input[type="submit"], input[type="button"], .filter-btn, .create-button { min-height: 48px !important; font-size: 18px !important; }
|
||||
|
||||
/* aviso de bienvenida: el boton de empezar en negro y la X sin caja */
|
||||
.welcome-banner .welcome-banner-start {
|
||||
background: var(--a26-fg) !important;
|
||||
color: var(--a26-bg) !important;
|
||||
box-shadow: 0 4px 0 var(--a26-bg) !important;
|
||||
border: var(--a26-bd) !important;
|
||||
}
|
||||
.welcome-banner .welcome-banner-close-btn {
|
||||
background: transparent !important;
|
||||
border: 0 !important;
|
||||
box-shadow: none !important;
|
||||
color: var(--a26-fg) !important;
|
||||
font-size: 22px !important;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
/* pie: gris y subrayado, sin amarillos */
|
||||
.oasis-footer .oasis-footer-center a, .oasis-footer .oasis-footer-center span,
|
||||
.oasis-footer .oasis-footer-center a span { color: var(--a26-muted) !important; }
|
||||
|
||||
/* la columna de contenido sin marco: el marco lo llevan las tarjetas */
|
||||
.main-column { border: 0 !important; box-shadow: none !important; border-radius: 0 !important; }
|
||||
|
||||
/* la sombra de Personal/Social no se corta contra la raya de la barra */
|
||||
.oasis-mobile-topbar { padding-bottom: 12px !important; }
|
||||
|
||||
/* ---------- asistente de bienvenida: botones grandes de un toque ---------- */
|
||||
.welcome-step { border: var(--a26-bd) !important; border-radius: var(--a26-radius) !important; }
|
||||
.welcome-banner .welcome-banner-start { font-size: 20px !important; min-height: 48px !important; padding: 8px 22px !important; }
|
||||
|
||||
/* mobile.css pinta en naranja los botones sin clase y todos los .filter-btn */
|
||||
button:not([class]), input[type="submit"]:not([class]), input[type="button"]:not([class]),
|
||||
.filter-btn, a.filter-btn, .conn-actions button, .peers-conn-actions button, .filter-box__button {
|
||||
background-color: var(--a26-bg) !important;
|
||||
color: var(--a26-fg) !important;
|
||||
border: var(--a26-bd) !important;
|
||||
border-radius: var(--a26-radius) !important;
|
||||
font-weight: 400 !important;
|
||||
font-size: 18px !important;
|
||||
height: auto !important;
|
||||
min-height: 48px !important;
|
||||
}
|
||||
.filter-btn.active, a.filter-btn.active { background-color: var(--a26-neon) !important; }
|
||||
|
||||
/* pantallas estrechas (360px): Nodos e Invitaciones solo con icono, para que
|
||||
los cuatro atajos no se corten */
|
||||
@media (max-width: 380px) {
|
||||
.oasis-bottombar .bb-pair .bb-lbl { display: none !important; }
|
||||
.oasis-bottombar .bb-pair .bb-ico { font-size: 22px !important; }
|
||||
.oasis-bottombar .bb-pair .bb-item { padding: 2px 6px !important; }
|
||||
.oasis-bottombar .bb-lbl { font-size: 10px !important; }
|
||||
.welcome-banner .update-banner-icon { display: none !important; }
|
||||
}
|
||||
|
||||
/* ---------- identidades (views/identities_view.js): una tarjeta por identidad ---------- */
|
||||
.identities .identity-list { list-style: none; margin: 0 0 16px; padding: 0; display: flex; flex-direction: column; gap: 10px; }
|
||||
.identities .identity-row { display: flex; flex-direction: column; align-items: stretch; gap: 10px; padding: 12px 14px; }
|
||||
.identities .identity-info { display: flex; flex-direction: column; gap: 2px; min-width: 0; border: 0; background: transparent; padding: 0; margin: 0; box-shadow: none; }
|
||||
.identities .identity-feed { font-family: monospace; font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.identities .identity-warn { font-size: 12px; }
|
||||
.identities .identity-row form { margin: 0; }
|
||||
.identities .identity-row button { width: 100%; margin: 0; }
|
||||
.identities .identity-active { text-align: center; font-weight: bold; padding: 10px 12px; border-radius: var(--a26-radius); }
|
||||
.identities .identity-create { display: flex; flex-direction: column; gap: 10px; margin: 0 0 16px; }
|
||||
.identities .identity-create input { width: 100%; box-sizing: border-box; margin: 0; }
|
||||
.identities .identity-create button { width: 100%; margin: 0; }
|
||||
.identities .identity-actions { display: flex; flex-direction: column; gap: 12px; align-items: stretch; border: 0; background: transparent; padding: 0; }
|
||||
.identities .identity-actions form { margin: 0; }
|
||||
.identities .identity-actions button { width: 100%; }
|
||||
.identities .identity-legacy { text-align: center; text-decoration: underline; }
|
||||
.identities .identity-row { border: var(--a26-bd) !important; border-radius: var(--a26-radius) !important; background: var(--a26-bg) !important; }
|
||||
.identities .identity-name { font-family: var(--a26-tit) !important; font-weight: 400 !important; text-transform: uppercase; font-size: 22px !important; }
|
||||
.identities .identity-feed { color: var(--a26-muted) !important; }
|
||||
.identities .identity-active { background: var(--a26-neon) !important; border: var(--a26-bd-fino) !important; color: var(--a26-fg) !important; }
|
||||
.identities .identity-warn { color: #c4003b !important; }
|
||||
|
||||
/* las barras de filtros de cada modulo: upstream las encoge (.75em) y les pone
|
||||
borde amarillo con selectores compuestos; aqui, del mismo tamaño que el resto */
|
||||
.tribe-mode-buttons .create-button, .ui-toolbar--filters .create-button, .mode-buttons .create-button,
|
||||
.mode-buttons-row .create-button, .filters .create-button, .filter-group .create-button,
|
||||
.activity-sub-filter .create-button, .activity-filter-grid .create-button {
|
||||
border: var(--a26-bd) !important;
|
||||
border-color: var(--a26-fg) !important;
|
||||
background: var(--a26-fg) !important;
|
||||
color: var(--a26-bg) !important;
|
||||
font-size: 18px !important;
|
||||
padding: 8px 16px !important;
|
||||
min-height: 48px !important;
|
||||
border-radius: var(--a26-radius) !important;
|
||||
line-height: 1.2 !important;
|
||||
}
|
||||
.tribe-mode-buttons .filter-btn, .ui-toolbar--filters .filter-btn, .mode-buttons .filter-btn,
|
||||
.mode-buttons-row .filter-btn, .mode-buttons-cols .filter-btn, .filters .filter-btn,
|
||||
.filter-group .filter-btn, .activity-sub-filter .filter-btn, .activity-filter-grid .filter-btn {
|
||||
border: var(--a26-bd) !important;
|
||||
font-size: 16px !important;
|
||||
padding: 6px 14px !important;
|
||||
min-height: 44px !important;
|
||||
border-radius: var(--a26-radius) !important;
|
||||
line-height: 1.2 !important;
|
||||
}
|
||||
|
||||
/* ---------- lo que tiene que seguir siendo pequeño ---------- */
|
||||
/* los iconos de accion de cada tarjeta (borrar, fijar, enlace...): en fila y redondos */
|
||||
.content-actions {
|
||||
display: flex !important; flex-direction: row !important; flex-wrap: wrap !important;
|
||||
justify-content: flex-end !important; align-items: center !important; gap: 6px !important;
|
||||
}
|
||||
.main-column .content-actions form, .content-actions .content-action-form { display: inline-flex !important; margin: 0 !important; width: auto !important; }
|
||||
.btn-singleview, button.btn-singleview, a.btn-singleview {
|
||||
width: 34px !important; height: 34px !important; min-width: 34px !important; min-height: 34px !important;
|
||||
padding: 0 !important; margin: 0 !important;
|
||||
display: inline-flex !important; align-items: center !important; justify-content: center !important;
|
||||
font-family: var(--a26-txt) !important; font-size: 15px !important; text-transform: none !important;
|
||||
background: var(--a26-bg) !important; color: var(--a26-fg) !important;
|
||||
border: var(--a26-bd-fino) !important; border-radius: 50% !important; box-shadow: none !important;
|
||||
}
|
||||
.btn-singleview.btn-delete { color: #c4003b !important; border-color: #c4003b !important; }
|
||||
|
||||
/* barras de filtros: chips en fila que se reparten el ancho, no un boton por linea */
|
||||
.mode-buttons, .mode-buttons-row, .mode-buttons-cols, .tribe-mode-buttons, .ui-toolbar--filters, .filters {
|
||||
display: flex !important; flex-direction: row !important; flex-wrap: wrap !important;
|
||||
justify-content: center !important; gap: 8px !important; margin: 0 0 14px !important;
|
||||
}
|
||||
.mode-buttons form, .mode-buttons-row form, .mode-buttons-cols form, .tribe-mode-buttons form, .filters > form:not(.filter-box) {
|
||||
display: flex !important; flex-direction: row !important; flex-wrap: wrap !important;
|
||||
justify-content: center !important; gap: 8px !important; width: auto !important; max-width: 100% !important; margin: 0 !important;
|
||||
}
|
||||
.mode-buttons .filter-btn, .mode-buttons-row .filter-btn, .mode-buttons-cols .filter-btn, .tribe-mode-buttons .filter-btn,
|
||||
.ui-toolbar--filters .filter-btn, .filters .filter-btn, .activity-filter-grid .filter-btn, .activity-sub-filter .filter-btn {
|
||||
width: auto !important; flex: 0 0 auto !important;
|
||||
min-height: 38px !important; height: auto !important;
|
||||
padding: 4px 12px !important; margin: 0 !important;
|
||||
font-size: 15px !important; box-shadow: 0 3px 0 var(--a26-fg) !important;
|
||||
border: var(--a26-bd-fino) !important;
|
||||
}
|
||||
.mode-buttons .create-button, .mode-buttons-row .create-button, .tribe-mode-buttons .create-button, .filters .create-button {
|
||||
flex: 1 1 100% !important; width: 100% !important; margin-top: 4px !important;
|
||||
}
|
||||
/* chips de la actividad (activity-filter-*) */
|
||||
.activity-filter-chips button, .activity-filter-grid button, .activity-sub-filter button, .activity-filter-col button {
|
||||
background: var(--a26-bg) !important; color: var(--a26-fg) !important; border: var(--a26-bd-fino) !important;
|
||||
}
|
||||
.activity-filter-chips button.active, .activity-filter-grid button.active, .activity-sub-filter button.active, .activity-filter-col button.active {
|
||||
background: var(--a26-neon) !important;
|
||||
}
|
||||
/* fechas y autores al pie de cada tarjeta: texto, no botones */
|
||||
.card-footer .date-link, .card-footer a.user-link, a.user-link, .date-link {
|
||||
font-family: var(--a26-txt) !important; text-transform: none !important; letter-spacing: 0 !important;
|
||||
font-size: 13px !important; box-shadow: none !important; border: 0 !important;
|
||||
background: transparent !important; padding: 0 !important; min-height: 0 !important;
|
||||
}
|
||||
a.user-link { font-weight: 700 !important; text-decoration: underline !important; }
|
||||
.card-footer { display: flex !important; flex-wrap: wrap; gap: 10px; align-items: center; color: var(--a26-muted) !important; }
|
||||
/* buscador de cada modulo */
|
||||
.filter-box { display: flex !important; gap: 8px !important; align-items: stretch !important; }
|
||||
.filter-box__input::placeholder, input::placeholder, textarea::placeholder { color: #777 !important; }
|
||||
.filter-box__button { font-size: 15px !important; padding: 4px 14px !important; box-shadow: none !important; }
|
||||
/* el pie de cada pagina y el id del perfil, en gris */
|
||||
.main-column [class*="feed-id"], .profile .feed-id, code { color: var(--a26-muted) !important; }
|
||||
|
||||
/* una sola caja por tarjeta: lo de dentro (cabecera, titulo, texto) sin marco */
|
||||
.main-column section, .main-column [class*="-card-"], .main-column .card-section, .main-column .card-header,
|
||||
.main-column .card-meta-row, .main-column .shop-title-row, .main-column [class*="-grid"],
|
||||
.main-column .feed-container, .main-column [class$="-card"] [class$="-card"] {
|
||||
border: 0 !important; box-shadow: none !important; background: transparent !important; border-radius: 0 !important;
|
||||
}
|
||||
.main-column [class$="-card"], .main-column [class*="-card "] {
|
||||
border: var(--a26-bd) !important; border-radius: var(--a26-radius) !important;
|
||||
background: var(--a26-bg) !important; padding: 12px !important; margin: 0 0 14px !important;
|
||||
}
|
||||
.main-column .card-header { padding: 0 0 8px !important; border-bottom: var(--a26-bd-fino) !important; margin-bottom: 8px !important; }
|
||||
.main-column [class$="-card"] .card-footer { border-top: var(--a26-bd-fino) !important; padding-top: 8px !important; margin-top: 8px !important; }
|
||||
.main-column hr { border: 0 !important; border-top: var(--a26-bd-fino) !important; }
|
||||
|
||||
/* titulos de las tarjetas: enlaces en negro */
|
||||
.main-column h1 a, .main-column h2 a, .main-column h3 a, .main-column [class*="title"] a {
|
||||
color: var(--a26-fg) !important; text-decoration: none !important;
|
||||
}
|
||||
.main-column [class*="-card"] .tribe-card-title, .main-column [class*="-card"] h2 { font-size: 22px !important; margin: 4px 0 8px !important; }
|
||||
|
||||
/* la etiqueta del tipo (IMAGENES, FOROS...) y los chips de estado */
|
||||
.pm-exposition-chip, .card-chips-row > span {
|
||||
background: var(--a26-fg) !important; color: var(--a26-bg) !important;
|
||||
border: 0 !important; border-radius: 8px !important; padding: 2px 8px !important;
|
||||
}
|
||||
.pm-exposition-chip *, .card-chips-row > span * { color: var(--a26-bg) !important; }
|
||||
.pm-exposition-text { font-family: var(--a26-tit) !important; letter-spacing: .04em; font-size: 13px !important; }
|
||||
.content-actions .btn-singleview.btn-content, .content-actions a.btn-content { border-color: var(--a26-fg) !important; color: var(--a26-fg) !important; }
|
||||
|
||||
/* opiniones y votos */
|
||||
.opinions-summary, .vote-btn, .score-btn {
|
||||
background: var(--a26-bg) !important; color: var(--a26-fg) !important;
|
||||
border: var(--a26-bd-fino) !important; border-radius: 10px !important; box-shadow: none !important;
|
||||
font-family: var(--a26-txt) !important; text-transform: none !important; font-size: 14px !important;
|
||||
}
|
||||
.post-image { border: var(--a26-bd-fino) !important; border-radius: 10px; }
|
||||
|
||||
/* tablas de detalle (fecha, estado, lugar...): filas claras, la etiqueta en gris */
|
||||
.main-column table, .main-column table tr, .main-column table td, .main-column table th, .main-column table tbody {
|
||||
background: var(--a26-bg) !important; color: var(--a26-fg) !important; border-color: var(--a26-fg) !important;
|
||||
}
|
||||
.main-column table { border: var(--a26-bd-fino) !important; border-radius: 10px !important; }
|
||||
.main-column table tr + tr td { border-top: 1px solid #ccc !important; }
|
||||
.main-column table td:first-child, .main-column table th, .main-column [class*="-info-label"] {
|
||||
color: var(--a26-muted) !important; font-weight: 700 !important; font-size: 13px !important;
|
||||
text-transform: uppercase; letter-spacing: .03em;
|
||||
}
|
||||
|
||||
/* perfil: los datos del habitante como lineas claras, sin cajas negras */
|
||||
.profile-sensors-box, .profile-sensors-box > span, .karma-line, .inhabitant-last-activity, .gpg-line,
|
||||
.inhabitant-stats-box, .inhabitant-stat, .profile-side-relationship .status {
|
||||
background: transparent !important; color: var(--a26-fg) !important; border: 0 !important; box-shadow: none !important;
|
||||
}
|
||||
.profile-sensors-box { display: flex !important; flex-direction: column !important; gap: 4px !important; align-items: center !important; }
|
||||
.profile-sensors-box strong, .profile-sensors-box a, .device-mobile { color: var(--a26-fg) !important; }
|
||||
.inhabitant-stat { border: var(--a26-bd-fino) !important; border-radius: 10px !important; padding: 4px 10px !important; }
|
||||
.profile-side-relationship .status { background: var(--a26-neon) !important; border: var(--a26-bd-fino) !important; border-radius: 8px !important; padding: 2px 10px !important; }
|
||||
.main-column [class*="feed-id"], .main-column a[href^="/author/"]:not(.user-link) { color: var(--a26-muted) !important; }
|
||||
.profile img.avatar, .inhabitant-photo { border: var(--a26-bd) !important; }
|
||||
|
||||
/* etiquetas (#asamblea) y la tasa ECO: chips claros */
|
||||
.card-tags { background: transparent !important; border: 0 !important; padding: 0 !important; display: flex !important; flex-wrap: wrap; gap: 6px; }
|
||||
.card-tags .tag-link, a.tag-link {
|
||||
background: var(--a26-bg) !important; color: var(--a26-fg) !important;
|
||||
border: var(--a26-bd-fino) !important; border-radius: 8px !important; padding: 2px 8px !important;
|
||||
text-decoration: none !important; font-weight: 700;
|
||||
}
|
||||
.eco-tax-chip, [class*="eco-tax-chip-"] {
|
||||
background: var(--a26-soft) !important; color: var(--a26-muted) !important;
|
||||
border: 1px solid #bbb !important; border-radius: 8px !important;
|
||||
}
|
||||
|
||||
/* los filtros de la actividad son enlaces .activity-chip */
|
||||
.activity-chip {
|
||||
background: var(--a26-bg) !important; color: var(--a26-fg) !important;
|
||||
border: var(--a26-bd-fino) !important; border-radius: 10px !important;
|
||||
padding: 4px 10px !important; font-weight: 700 !important; text-decoration: none !important;
|
||||
}
|
||||
.activity-chip.active { background: var(--a26-neon) !important; }
|
||||
.activity-chip:hover { color: var(--a26-fg) !important; border-color: var(--a26-fg) !important; }
|
||||
|
||||
/* ---------- fichas de filtro de 1.3.3 (OasisMobile.css las pinta en amarillo y gris) ---------- */
|
||||
/* mismo selector que alli: gana por ir detras */
|
||||
.main-column :is(.tribe-mode-buttons, .mode-buttons, .mode-buttons-row, .filter-group) {
|
||||
justify-content: center !important;
|
||||
gap: 8px !important;
|
||||
padding: 4px 0 !important;
|
||||
}
|
||||
.main-column :is(.tribe-mode-buttons, .mode-buttons, .mode-buttons-row, .filter-group) .filter-btn:not(.create-button) {
|
||||
font-family: var(--a26-tit) !important;
|
||||
font-weight: 400 !important;
|
||||
font-size: 15px !important;
|
||||
letter-spacing: .02em !important;
|
||||
padding: 4px 12px !important;
|
||||
min-height: 38px !important;
|
||||
border: var(--a26-bd-fino) !important;
|
||||
border-radius: var(--a26-radius) !important;
|
||||
background: var(--a26-bg) !important;
|
||||
color: var(--a26-fg) !important;
|
||||
box-shadow: 0 3px 0 var(--a26-fg) !important;
|
||||
}
|
||||
.main-column :is(.tribe-mode-buttons, .mode-buttons, .mode-buttons-row, .filter-group) .filter-btn.active:not(.create-button) {
|
||||
background: var(--a26-neon) !important;
|
||||
border-color: var(--a26-fg) !important;
|
||||
color: var(--a26-fg) !important;
|
||||
}
|
||||
.main-column :is(.tribe-mode-buttons, .mode-buttons, .mode-buttons-row, .filter-group) .create-button { min-height: 48px !important; }
|
||||
|
||||
/* acciones de la tarjeta: iconos SVG en negro dentro del circulo */
|
||||
.main-column .content-actions .btn-singleview { width: 36px !important; height: 36px !important; }
|
||||
.btn-singleview .btn-icon { width: 18px !important; height: 18px !important; }
|
||||
.btn-singleview.btn-pin-on { background: var(--a26-neon) !important; }
|
||||
|
||||
/* hilos del foro (lista tipo archivo de 1.3.3): tarjeta negra sin la raya naranja */
|
||||
.main-column .mailing-archive-item {
|
||||
border: var(--a26-bd-fino) !important;
|
||||
border-radius: var(--a26-radius) !important;
|
||||
background: var(--a26-bg) !important;
|
||||
padding: 12px !important;
|
||||
margin: 0 0 14px !important;
|
||||
}
|
||||
.main-column .mailing-archive-item a { color: var(--a26-fg) !important; }
|
||||
.main-column a.forum-category {
|
||||
background: var(--a26-fg) !important; color: var(--a26-bg) !important;
|
||||
border: 0 !important; border-radius: 8px !important; padding: 2px 8px !important;
|
||||
font-family: var(--a26-tit) !important; text-decoration: none !important; letter-spacing: .03em;
|
||||
}
|
||||
|
||||
/* restos de naranja de style.css y mobile.css */
|
||||
.oasis-id-copy, input.oasis-id-copy, input.oasis-id-copy[readonly] {
|
||||
background: var(--a26-soft) !important; border: var(--a26-bd-fino) !important; color: var(--a26-muted) !important;
|
||||
}
|
||||
.oasis-menu-btn.oasis-menu-open, .oasis-menu-btn.oasis-menu-close {
|
||||
background: var(--a26-bg) !important; border: var(--a26-bd-fino) !important; color: var(--a26-fg) !important;
|
||||
}
|
||||
img.inhabitant-photo-details { border: var(--a26-bd) !important; }
|
||||
.welcome-action { color: var(--a26-fg) !important; }
|
||||
label.welcome-ux-option {
|
||||
background: var(--a26-bg) !important; border: var(--a26-bd-fino) !important;
|
||||
border-radius: var(--a26-radius) !important; box-shadow: 0 4px 0 var(--a26-fg) !important;
|
||||
}
|
||||
label.welcome-ux-option:has(input:checked) { background: var(--a26-neon) !important; }
|
||||
|
||||
/* chat: burbujas con marco negro y las reacciones como chips pequeños
|
||||
(el estilo general de botones de Acampada las hacia de 48px a ancho completo) */
|
||||
.chat-bubble-row .chat-message {
|
||||
background: var(--a26-bg) !important; color: var(--a26-fg) !important;
|
||||
border: var(--a26-bd-fino) !important; border-radius: 14px 14px 14px 4px !important;
|
||||
}
|
||||
.chat-bubble-row-self .chat-message { background: #e5fcff !important; border-radius: 14px 14px 4px 14px !important; }
|
||||
.chat-bubble-sender, .chat-bubble-sender-owner { color: var(--a26-fg) !important; }
|
||||
.chat-bubble-time { color: var(--a26-muted) !important; }
|
||||
.main-column .chat-msg-actions { display: flex !important; flex-wrap: wrap !important; gap: 4px !important; }
|
||||
.main-column .chat-msg-actions form, .main-column form.chat-react-form { display: inline-flex !important; width: auto !important; margin: 0 !important; }
|
||||
.main-column button.chat-react-btn, button.chat-react-btn {
|
||||
width: auto !important; min-height: 0 !important; height: auto !important;
|
||||
padding: 1px 8px !important; margin: 0 !important;
|
||||
font-size: 13px !important; line-height: 1.4 !important;
|
||||
font-family: "Noto Color Emoji", "Apple Color Emoji", "Segoe UI Emoji", sans-serif !important;
|
||||
text-transform: none !important; letter-spacing: 0 !important;
|
||||
background: var(--a26-bg) !important; color: var(--a26-fg) !important;
|
||||
border: 1px solid var(--a26-fg) !important; border-radius: 10px !important; box-shadow: none !important;
|
||||
}
|
||||
/* el 📌 va en la esquina: el texto deja su hueco (el padding general de la burbuja lo pisaba) */
|
||||
.chat-bubble-row .chat-message.chat-has-pin-toggle { position: relative !important; padding-right: 44px !important; }
|
||||
.chat-day-chip { background: var(--a26-fg) !important; color: var(--a26-bg) !important; border: 0 !important; }
|
||||
.chat-reply-cancel,
|
||||
.chat-message-meta .chat-reply-btn { color: var(--a26-fg) !important; }
|
||||
|
||||
/* ---------- skins en Ajustes ---------- */
|
||||
.skin-card {
|
||||
background: var(--a26-bg) !important; color: var(--a26-fg) !important;
|
||||
border: var(--a26-bd) !important; border-radius: var(--a26-radius) !important;
|
||||
box-shadow: 0 5px 0 var(--a26-fg) !important;
|
||||
}
|
||||
.skin-card .skin-shot { border: var(--a26-bd-fino); }
|
||||
.skin-card .skin-name {
|
||||
font-family: var(--a26-tit) !important; font-weight: 400 !important;
|
||||
text-transform: uppercase; letter-spacing: .02em; font-size: 21px !important;
|
||||
}
|
||||
.skin-card.is-current { background: var(--a26-neon) !important; color: var(--a26-fg) !important; border: var(--a26-bd) !important; }
|
||||
.skin-card:active { transform: translateY(4px); box-shadow: 0 1px 0 var(--a26-fg) !important; background: var(--a26-neon) !important; }
|
||||
|
||||
/* ---------- letra y botones mas grandes, para leer sin gafas ---------- */
|
||||
body { font-size: 18px; }
|
||||
.ln-ico { width: 64px !important; height: 64px !important; font-size: 28px !important; }
|
||||
.ln-lbl { font-size: 13px !important; font-weight: 700 !important; white-space: normal !important; overflow-wrap: normal !important; word-break: normal !important; line-height: 1.15 !important; letter-spacing: -.02em; overflow: visible !important; text-overflow: clip !important; max-width: calc(100% + 10px) !important; }
|
||||
.ln-title { font-size: 20px !important; }
|
||||
.ln-grid { gap: 20px 8px !important; }
|
||||
.oasis-bottombar .bb-ico { font-size: 25px !important; }
|
||||
.oasis-bottombar .bb-lbl { font-size: 12.5px !important; font-weight: 700 !important; }
|
||||
.main-column :is(.tribe-mode-buttons, .mode-buttons, .mode-buttons-row, .filter-group) .filter-btn:not(.create-button) {
|
||||
font-size: 18px !important; min-height: 46px !important; padding: 6px 16px !important;
|
||||
}
|
||||
.activity-chip { font-size: 16px !important; padding: 6px 12px !important; }
|
||||
.main-column .content-actions .btn-singleview { width: 44px !important; height: 44px !important; min-width: 44px !important; }
|
||||
.btn-singleview .btn-icon { width: 22px !important; height: 22px !important; }
|
||||
.main-column button.chat-react-btn, button.chat-react-btn { font-size: 17px !important; padding: 3px 10px !important; }
|
||||
.oasis-mobile-topbar .omt-menus .oasis-menu-btn { font-size: 22px !important; min-height: 48px !important; }
|
||||
.tags-header p { font-size: 16px !important; }
|
||||
.card-footer .date-link, .card-footer a.user-link, a.user-link, .date-link { font-size: 15px !important; }
|
||||
input, textarea, select { font-size: 18px !important; min-height: 50px; }
|
||||
|
|
@ -89,6 +89,7 @@ code {
|
|||
}
|
||||
|
||||
.search-input,
|
||||
.feed-search-input,
|
||||
.activity-search-input {
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
|
|
@ -261,7 +262,7 @@ h3 { font-size: 1em !important; }
|
|||
display: flex !important;
|
||||
flex-direction: row !important;
|
||||
flex-wrap: wrap !important;
|
||||
justify-content: flex-start !important;
|
||||
justify-content: center !important;
|
||||
align-items: center !important;
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
|
|
@ -288,8 +289,8 @@ h3 { font-size: 1em !important; }
|
|||
}
|
||||
.inhabitant-action .filter-btn {
|
||||
min-height: 0 !important;
|
||||
padding: 2px 9px !important;
|
||||
font-size: .75em !important;
|
||||
padding: 7px 12px !important;
|
||||
font-size: 13px !important;
|
||||
white-space: nowrap !important;
|
||||
}
|
||||
.profile-sensors-box {
|
||||
|
|
@ -315,8 +316,8 @@ h3 { font-size: 1em !important; }
|
|||
.activity-filter-col .filter-btn,
|
||||
.activity-sub-filter .filter-btn {
|
||||
min-height: 0 !important;
|
||||
padding: 2px 9px !important;
|
||||
font-size: .75em !important;
|
||||
padding: 7px 12px !important;
|
||||
font-size: 13px !important;
|
||||
white-space: nowrap !important;
|
||||
}
|
||||
|
||||
|
|
@ -450,6 +451,10 @@ h3 { font-size: 1em !important; }
|
|||
grid-template-columns: repeat(2, 1fr) !important;
|
||||
}
|
||||
|
||||
.tribe-media-grid {
|
||||
grid-template-columns: repeat(2, 1fr) !important;
|
||||
}
|
||||
|
||||
.tribe-overview-grid {
|
||||
grid-template-columns: 1fr !important;
|
||||
}
|
||||
|
|
@ -462,11 +467,24 @@ h3 { font-size: 1em !important; }
|
|||
height: 180px !important;
|
||||
}
|
||||
|
||||
.tribe-votation-option {
|
||||
flex-direction: column !important;
|
||||
align-items: flex-start !important;
|
||||
}
|
||||
|
||||
.tribe-content-header {
|
||||
flex-direction: column !important;
|
||||
gap: 8px !important;
|
||||
}
|
||||
|
||||
.tribe-content-filters {
|
||||
flex-wrap: wrap !important;
|
||||
}
|
||||
|
||||
.tribe-media-filters {
|
||||
flex-wrap: wrap !important;
|
||||
}
|
||||
|
||||
.forum-card {
|
||||
flex-direction: column !important;
|
||||
}
|
||||
|
|
@ -492,28 +510,28 @@ h3 { font-size: 1em !important; }
|
|||
}
|
||||
|
||||
.forum-comment {
|
||||
margin-left: 0;
|
||||
padding-left: 8px;
|
||||
margin-left: 0 !important;
|
||||
padding-left: 8px !important;
|
||||
}
|
||||
|
||||
.comment-body-row {
|
||||
flex-direction: column;
|
||||
flex-direction: column !important;
|
||||
}
|
||||
|
||||
.comment-vote-col,
|
||||
.comment-text-col {
|
||||
width: 100%;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.forum-score-box,
|
||||
.forum-score-form {
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
flex-direction: row !important;
|
||||
justify-content: center !important;
|
||||
}
|
||||
|
||||
.new-message-form textarea,
|
||||
.comment-textarea {
|
||||
width: 100%;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
[style*="grid-template-columns: repeat(6"] {
|
||||
|
|
@ -609,7 +627,6 @@ a.filter-btn,
|
|||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
min-width: 0 !important;
|
||||
flex: 0 0 auto !important;
|
||||
height: 42px !important;
|
||||
font-size: 16px !important;
|
||||
box-sizing: border-box !important;
|
||||
|
|
@ -656,7 +673,7 @@ a.filter-btn,
|
|||
padding: 9px 16px !important;
|
||||
}
|
||||
.filters .ui-toolbar--filters {
|
||||
justify-content: flex-start !important;
|
||||
justify-content: center !important;
|
||||
align-items: center !important;
|
||||
gap: 6px !important;
|
||||
}
|
||||
|
|
@ -712,15 +729,6 @@ a.filter-btn,
|
|||
max-width: 100% !important;
|
||||
overflow-wrap: anywhere !important;
|
||||
}
|
||||
.card-field .card-value.user-link,
|
||||
.card-field .card-value .user-link {
|
||||
display: inline-block !important;
|
||||
vertical-align: bottom !important;
|
||||
max-width: 100% !important;
|
||||
white-space: nowrap !important;
|
||||
overflow: hidden !important;
|
||||
text-overflow: ellipsis !important;
|
||||
}
|
||||
|
||||
.market-grid,
|
||||
.shop-products-grid,
|
||||
|
|
@ -810,14 +818,14 @@ a.filter-btn,
|
|||
margin-left: 0 !important;
|
||||
}
|
||||
|
||||
input.oasis-id-copy[readonly] {
|
||||
.oasis-id-copy {
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
box-sizing: border-box !important;
|
||||
font-family: monospace !important;
|
||||
font-size: clamp(8px, 2.5vw, 12px) !important;
|
||||
font-size: 12px !important;
|
||||
text-align: center !important;
|
||||
padding: 8px 6px !important;
|
||||
padding: 8px !important;
|
||||
border: 1px solid #FFA500 !important;
|
||||
border-radius: 8px !important;
|
||||
background: rgba(255, 165, 0, 0.08) !important;
|
||||
|
|
@ -842,8 +850,6 @@ a.create-button {
|
|||
|
||||
.peers-list .block-info-table,
|
||||
.peers-technical-block .block-info-table,
|
||||
.viewer-hidden-block .block-info-table,
|
||||
.parliament-rows-table,
|
||||
.gov-overview {
|
||||
display: block !important;
|
||||
width: 100% !important;
|
||||
|
|
@ -854,23 +860,17 @@ a.create-button {
|
|||
}
|
||||
.peers-list .block-info-table tbody,
|
||||
.peers-technical-block .block-info-table tbody,
|
||||
.viewer-hidden-block .block-info-table tbody,
|
||||
.parliament-rows-table tbody,
|
||||
.gov-overview tbody {
|
||||
display: block !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
.gov-overview thead,
|
||||
.parliament-rows-table thead { display: none !important; }
|
||||
.gov-overview thead { display: none !important; }
|
||||
.peers-list .block-info-table tr:first-child,
|
||||
.peers-technical-block .block-info-table tr:first-child,
|
||||
.viewer-hidden-block .block-info-table tr:first-child {
|
||||
.peers-technical-block .block-info-table tr:first-child {
|
||||
display: none !important;
|
||||
}
|
||||
.peers-list .block-info-table tr,
|
||||
.peers-technical-block .block-info-table tr,
|
||||
.viewer-hidden-block .block-info-table tr,
|
||||
.parliament-rows-table tbody tr,
|
||||
.gov-overview tbody tr {
|
||||
display: block !important;
|
||||
width: 100% !important;
|
||||
|
|
@ -883,8 +883,6 @@ a.create-button {
|
|||
}
|
||||
.peers-list .block-info-table td,
|
||||
.peers-technical-block .block-info-table td,
|
||||
.viewer-hidden-block .block-info-table td,
|
||||
.parliament-rows-table tbody td,
|
||||
.gov-overview tbody td {
|
||||
display: flex !important;
|
||||
justify-content: space-between !important;
|
||||
|
|
@ -900,8 +898,6 @@ a.create-button {
|
|||
}
|
||||
.peers-list .block-info-table td::before,
|
||||
.peers-technical-block .block-info-table td::before,
|
||||
.viewer-hidden-block .block-info-table td::before,
|
||||
.parliament-rows-table tbody td::before,
|
||||
.gov-overview tbody td::before {
|
||||
content: attr(data-label);
|
||||
flex: 0 0 auto !important;
|
||||
|
|
@ -914,606 +910,7 @@ a.create-button {
|
|||
white-space: nowrap !important;
|
||||
word-break: normal !important;
|
||||
}
|
||||
.peers-technical-block .block-info-table td[data-label=""],
|
||||
.viewer-hidden-block .block-info-table td[data-label=""] { justify-content: center !important; }
|
||||
.peers-technical-block .block-info-table td[data-label=""]::before,
|
||||
.viewer-hidden-block .block-info-table td[data-label=""]::before { content: none !important; }
|
||||
.peers-technical-block .block-info-table td[data-label=""] { justify-content: center !important; }
|
||||
.peers-technical-block .block-info-table td[data-label=""]::before { content: none !important; }
|
||||
.peers-list .block-info-table td .peer-key,
|
||||
.peers-technical-block .block-info-table td .peer-key { text-align: right !important; }
|
||||
.phone-history td.phone-dir,
|
||||
.phone-contact .phone-dir,
|
||||
.phone-dir {
|
||||
display: none !important;
|
||||
}
|
||||
.phone-contact {
|
||||
gap: 6px !important;
|
||||
padding: 8px !important;
|
||||
}
|
||||
.phone-contact .user-link {
|
||||
flex: 1 1 100% !important;
|
||||
min-width: 0 !important;
|
||||
overflow: hidden !important;
|
||||
text-overflow: ellipsis !important;
|
||||
white-space: nowrap !important;
|
||||
}
|
||||
.phone-contact .date-link {
|
||||
flex: 0 1 auto !important;
|
||||
font-size: 12px !important;
|
||||
}
|
||||
.phone-history,
|
||||
.phone-history tbody {
|
||||
display: block !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
.phone-history tr:first-child {
|
||||
display: none !important;
|
||||
}
|
||||
.phone-history tr {
|
||||
display: block !important;
|
||||
width: 100% !important;
|
||||
box-sizing: border-box !important;
|
||||
border: 1px solid #444 !important;
|
||||
border-radius: 10px !important;
|
||||
margin: 0 0 8px 0 !important;
|
||||
padding: 4px 10px !important;
|
||||
}
|
||||
.phone-history td {
|
||||
display: flex !important;
|
||||
justify-content: space-between !important;
|
||||
align-items: baseline !important;
|
||||
gap: 10px !important;
|
||||
width: 100% !important;
|
||||
border: 0 !important;
|
||||
padding: 3px 0 !important;
|
||||
font-size: 12px !important;
|
||||
text-align: right !important;
|
||||
}
|
||||
.phone-history td::before {
|
||||
content: attr(data-label);
|
||||
flex: 0 0 auto !important;
|
||||
font-weight: 700 !important;
|
||||
text-transform: uppercase;
|
||||
font-size: 10px !important;
|
||||
letter-spacing: .03em;
|
||||
opacity: .7 !important;
|
||||
text-align: left !important;
|
||||
white-space: nowrap !important;
|
||||
}
|
||||
.phone-history td.phone-row-actions {
|
||||
justify-content: flex-end !important;
|
||||
padding: 0 !important;
|
||||
margin-top: -6px !important;
|
||||
}
|
||||
.phone-history .phone-row-delete .btn-singleview,
|
||||
.phone-latest-row .phone-row-delete .btn-singleview {
|
||||
width: 32px !important;
|
||||
height: 32px !important;
|
||||
min-width: 0 !important;
|
||||
min-height: 0 !important;
|
||||
line-height: 1 !important;
|
||||
padding: 0 !important;
|
||||
font-size: 14px !important;
|
||||
}
|
||||
.phone-history td.phone-row-actions::before {
|
||||
content: none !important;
|
||||
}
|
||||
.phone-latest-row .phone-row-delete {
|
||||
margin-left: auto !important;
|
||||
}
|
||||
.phone-history td .user-link {
|
||||
min-width: 0 !important;
|
||||
overflow: hidden !important;
|
||||
text-overflow: ellipsis !important;
|
||||
white-space: nowrap !important;
|
||||
}
|
||||
.peers-list .block-info-table td .ubi-tick-ok,
|
||||
.peers-list .block-info-table td .peer-tor-no,
|
||||
.peers-technical-block .block-info-table td .ubi-tick-ok,
|
||||
.peers-technical-block .block-info-table td .peer-tor-no {
|
||||
padding: 0 !important;
|
||||
line-height: 1.2 !important;
|
||||
}
|
||||
|
||||
.conn-actions,
|
||||
.peers-conn-actions {
|
||||
display: flex !important;
|
||||
flex-direction: row !important;
|
||||
flex-wrap: wrap !important;
|
||||
gap: 8px !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
.conn-actions form,
|
||||
.peers-conn-actions form {
|
||||
flex: 1 1 45% !important;
|
||||
min-width: 130px !important;
|
||||
max-width: 100% !important;
|
||||
margin: 0 !important;
|
||||
display: block !important;
|
||||
}
|
||||
.conn-actions form button,
|
||||
.peers-conn-actions form button {
|
||||
width: 100% !important;
|
||||
min-width: 0 !important;
|
||||
}
|
||||
|
||||
.relationship-status .relationship-actions { flex-direction: column !important; align-items: stretch !important; gap: 8px !important; }
|
||||
.relationship-status .relationship-actions form,
|
||||
.relationship-status .relationship-actions button { width: 100% !important; }
|
||||
|
||||
.profile-sensors-box { align-items: center !important; text-align: center !important; }
|
||||
.profile-sensors-box > * { margin-left: auto !important; margin-right: auto !important; justify-content: center !important; }
|
||||
|
||||
.profile-layout,
|
||||
.profile-layout .tribe-side,
|
||||
.profile-layout .tribe-main {
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
min-width: 0 !important;
|
||||
box-sizing: border-box !important;
|
||||
}
|
||||
.profile-side-description,
|
||||
.profile-side-description p,
|
||||
.trending-card .card-section,
|
||||
.feed-text {
|
||||
word-break: normal !important;
|
||||
overflow-wrap: break-word !important;
|
||||
}
|
||||
|
||||
.invite-qr-card { display: flex !important; justify-content: center !important; border: none !important; background: transparent !important; padding: 0 !important; box-shadow: none !important; margin: 0 auto !important; }
|
||||
.invite-qr-img { display: block; margin: 0 auto; }
|
||||
|
||||
.stats-kpi { display: flex !important; flex-direction: column !important; align-items: center !important; text-align: center !important; gap: 2px !important; }
|
||||
.stats-kpi-label,
|
||||
.stats-kpi-value { width: 100% !important; text-align: center !important; }
|
||||
|
||||
.main-column audio,
|
||||
.main-column video { width: 100% !important; max-width: 100% !important; }
|
||||
|
||||
.main-column section:has(.chat-messages-list) { border: none !important; background: transparent !important; padding: 0 !important; box-shadow: none !important; }
|
||||
.tribe-details:has(.chat-messages-list) { display: block !important; border: none !important; background: transparent !important; padding: 6px !important; margin: 0 !important; box-shadow: none !important; }
|
||||
.chat-full-width { border: none !important; background: transparent !important; padding: 0 !important; }
|
||||
.chat-messages-list { padding: 0 !important; }
|
||||
.chat-message { max-width: 92% !important; }
|
||||
.chat-message.chat-poll { max-width: 97% !important; }
|
||||
.feed-workspace {
|
||||
display: block !important;
|
||||
}
|
||||
.feed-side {
|
||||
display: none !important;
|
||||
}
|
||||
.tribe-info-table,
|
||||
.tribe-info-table tbody,
|
||||
.tribe-info-table tr,
|
||||
.tribe-info-table td {
|
||||
display: block !important;
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
box-sizing: border-box !important;
|
||||
text-align: left !important;
|
||||
overflow: visible !important;
|
||||
}
|
||||
.tribe-info-table td .user-link {
|
||||
display: inline-block !important;
|
||||
max-width: 100% !important;
|
||||
white-space: nowrap !important;
|
||||
overflow: hidden !important;
|
||||
text-overflow: ellipsis !important;
|
||||
}
|
||||
.school-lesson-summary {
|
||||
flex-wrap: wrap !important;
|
||||
}
|
||||
.school-lesson-summary-title {
|
||||
flex: 1 1 100% !important;
|
||||
min-width: 0 !important;
|
||||
white-space: normal !important;
|
||||
overflow-wrap: anywhere !important;
|
||||
}
|
||||
.school-lesson-summary-meta {
|
||||
flex: 1 1 100% !important;
|
||||
white-space: normal !important;
|
||||
}
|
||||
.feed-votes-summary {
|
||||
flex-wrap: wrap !important;
|
||||
max-width: 100% !important;
|
||||
min-width: 0 !important;
|
||||
}
|
||||
.feed-votes-summary .vote-category {
|
||||
white-space: nowrap !important;
|
||||
flex: 0 0 auto !important;
|
||||
}
|
||||
.tribe-open-invite-code {
|
||||
font-size: .85em !important;
|
||||
}
|
||||
|
||||
.school-progress-row {
|
||||
flex-direction: column !important;
|
||||
align-items: flex-start !important;
|
||||
gap: 6px !important;
|
||||
}
|
||||
.school-progress-issue {
|
||||
width: 100% !important;
|
||||
}
|
||||
.school-progress-issue input[type="text"] {
|
||||
max-width: 100% !important;
|
||||
flex: 1 1 100% !important;
|
||||
}
|
||||
|
||||
.subscription-box-block {
|
||||
display: flex !important;
|
||||
flex-wrap: wrap !important;
|
||||
align-items: center !important;
|
||||
gap: 8px !important;
|
||||
}
|
||||
.subscription-box-block .subscription-form { margin: 0 !important; }
|
||||
|
||||
.profile-module-section,
|
||||
.profile-module-section .trending-card,
|
||||
.profile-module-section .card-body,
|
||||
.profile-module-section .card-section {
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
min-width: 0 !important;
|
||||
box-sizing: border-box !important;
|
||||
}
|
||||
.card-section-action {
|
||||
display: flex !important;
|
||||
justify-content: center !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
.card-section-action .keep-reading-link {
|
||||
text-align: center !important;
|
||||
}
|
||||
.card-field.image-container,.card-field.video-container,.card-field.audio-container { justify-content: center !important; }
|
||||
.card-field.media-left { justify-content: flex-start !important; }
|
||||
|
||||
.card-footer .date-link,
|
||||
.card-footer .user-link { padding: 0 10px !important; font-size: 12px !important; }
|
||||
.card-meta-row .opinions-summary,
|
||||
.card-meta-row .comments-summary-link { padding: 0 10px !important; font-size: 12px !important; }
|
||||
|
||||
.parliament-rows-table tbody td[data-label=""] { justify-content: center !important; }
|
||||
.parliament-rows-table tbody td[data-label=""]::before { content: none !important; }
|
||||
.parliament-rows-table tbody td .parliament-avatar-cell {
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
gap: 8px !important;
|
||||
justify-content: flex-end !important;
|
||||
}
|
||||
.parliament-rows-table tbody td .parliament-leader-avatar { width: 32px !important; height: 32px !important; }
|
||||
.parliament-rows-table tbody td .parliament-method-img { max-height: 28px !important; width: auto !important; }
|
||||
.parliament-rows-table tbody td form { margin: 0 !important; }
|
||||
|
||||
.hemi-legend {
|
||||
flex-direction: column !important;
|
||||
align-items: flex-start !important;
|
||||
gap: 6px !important;
|
||||
justify-content: flex-start !important;
|
||||
}
|
||||
.hemi-legend-item {
|
||||
width: 100% !important;
|
||||
flex-wrap: nowrap !important;
|
||||
justify-content: flex-start !important;
|
||||
}
|
||||
.hemi-legend-count { margin-left: auto !important; }
|
||||
.hemi-modes { flex-wrap: wrap !important; }
|
||||
.room-item .mailing-archive-head{flex-wrap:wrap}
|
||||
|
||||
.oasis-nav-side-toggle {
|
||||
position: absolute !important;
|
||||
width: 1px !important;
|
||||
height: 1px !important;
|
||||
opacity: 0 !important;
|
||||
pointer-events: none !important;
|
||||
}
|
||||
.oasis-nav-side-toggle ~ .header {
|
||||
display: flex !important;
|
||||
flex-direction: row !important;
|
||||
flex-wrap: wrap !important;
|
||||
align-items: center !important;
|
||||
gap: 8px 6px !important;
|
||||
padding: 8px 8px 4px !important;
|
||||
min-height: 60px !important;
|
||||
position: fixed !important;
|
||||
top: 0 !important;
|
||||
left: 0 !important;
|
||||
right: 0 !important;
|
||||
z-index: 900 !important;
|
||||
box-sizing: border-box !important;
|
||||
}
|
||||
.oasis-nav-side-toggle ~ .header + * ,
|
||||
.oasis-nav-side-toggle ~ .header ~ .update-banner:first-of-type {
|
||||
margin-top: 0 !important;
|
||||
}
|
||||
body:has(.oasis-nav-side-toggle) {
|
||||
padding-top: 64px !important;
|
||||
}
|
||||
body:has(:is(input:not([type="hidden"]):not([type="checkbox"]):not([type="radio"]), textarea, select):focus) .oasis-nav-side-toggle ~ .header {
|
||||
position: absolute !important;
|
||||
}
|
||||
.oasis-nav-side-toggle ~ .header .top-bar-left,
|
||||
.oasis-nav-side-toggle ~ .header .top-bar-right,
|
||||
.oasis-nav-side-toggle ~ .header .top-bar-mid {
|
||||
display: contents !important;
|
||||
}
|
||||
.oasis-nav-side-toggle ~ .header .top-bar-left a.logo-icon {
|
||||
order: 1 !important;
|
||||
flex: 0 0 auto !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
.oasis-nav-side-toggle ~ .header .top-bar-left a.logo-icon img.logo-icon {
|
||||
width: 44px !important;
|
||||
height: 44px !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
.oasis-nav-side-toggle ~ .header .oasis-mobile-menus {
|
||||
order: 2 !important;
|
||||
display: flex !important;
|
||||
flex: 1 1 auto !important;
|
||||
flex-wrap: nowrap !important;
|
||||
justify-content: flex-end !important;
|
||||
align-items: center !important;
|
||||
gap: 5px !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
.oasis-nav-side-toggle ~ .header .oasis-menu-slot {
|
||||
display: inline-flex !important;
|
||||
}
|
||||
.oasis-nav-side-toggle ~ .header .oasis-menu-btn {
|
||||
display: inline-flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
gap: 4px !important;
|
||||
min-height: 44px !important;
|
||||
padding: 6px 9px !important;
|
||||
white-space: nowrap !important;
|
||||
cursor: pointer !important;
|
||||
}
|
||||
.oasis-nav-side-toggle ~ .header .oasis-menu-slot .oasis-menu-close {
|
||||
display: none !important;
|
||||
}
|
||||
#nav-left-toggle:checked ~ .header .oasis-menu-slot-left .oasis-menu-open,
|
||||
#nav-right-toggle:checked ~ .header .oasis-menu-slot-right .oasis-menu-open {
|
||||
display: none !important;
|
||||
}
|
||||
#nav-left-toggle:checked ~ .header .oasis-menu-slot-left .oasis-menu-close,
|
||||
#nav-right-toggle:checked ~ .header .oasis-menu-slot-right .oasis-menu-close {
|
||||
display: inline-flex !important;
|
||||
}
|
||||
.oasis-nav-side-toggle ~ .header .top-bar-left nav {
|
||||
order: 3 !important;
|
||||
}
|
||||
.oasis-nav-side-toggle ~ .header .top-bar-right nav {
|
||||
order: 4 !important;
|
||||
}
|
||||
.oasis-nav-side-toggle ~ .header .top-bar-left nav,
|
||||
.oasis-nav-side-toggle ~ .header .top-bar-right nav {
|
||||
flex: 1 1 calc(50% - 4px) !important;
|
||||
width: auto !important;
|
||||
min-width: 0 !important;
|
||||
}
|
||||
.oasis-nav-side-toggle ~ .header .top-bar-left nav ul,
|
||||
.oasis-nav-side-toggle ~ .header .top-bar-right nav ul {
|
||||
display: grid !important;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr)) !important;
|
||||
gap: 4px !important;
|
||||
list-style: none !important;
|
||||
padding: 0 !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
.oasis-nav-side-toggle ~ .header .top-bar-left nav ul li,
|
||||
.oasis-nav-side-toggle ~ .header .top-bar-right nav ul li {
|
||||
margin: 0 !important;
|
||||
min-width: 0 !important;
|
||||
}
|
||||
.oasis-nav-side-toggle ~ .header .top-bar-left nav ul li a,
|
||||
.oasis-nav-side-toggle ~ .header .top-bar-right nav ul li a {
|
||||
position: relative !important;
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
width: 100% !important;
|
||||
min-height: 50px !important;
|
||||
padding: 0 !important;
|
||||
font-size: 0 !important;
|
||||
text-decoration: none !important;
|
||||
box-sizing: border-box !important;
|
||||
}
|
||||
.oasis-nav-side-toggle ~ .header nav ul li a .nav-text {
|
||||
display: none !important;
|
||||
}
|
||||
.oasis-nav-side-toggle ~ .header nav ul li a .emoji {
|
||||
font-size: 1.6rem !important;
|
||||
line-height: 1 !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
.oasis-nav-side-toggle ~ .header nav ul li a .inbox-badge {
|
||||
position: absolute !important;
|
||||
top: 3px !important;
|
||||
right: 3px !important;
|
||||
margin: 0 !important;
|
||||
font-size: 0.66rem !important;
|
||||
}
|
||||
.oasis-nav-side-toggle ~ .main-content {
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
}
|
||||
.oasis-nav-side-toggle ~ .main-content .main-column {
|
||||
order: 0 !important;
|
||||
}
|
||||
.oasis-nav-side-toggle ~ .main-content .sidebar-left,
|
||||
.oasis-nav-side-toggle ~ .main-content .sidebar-right {
|
||||
display: none !important;
|
||||
}
|
||||
#nav-left-toggle:checked ~ .main-content .sidebar-left,
|
||||
#nav-right-toggle:checked ~ .main-content .sidebar-right {
|
||||
display: block !important;
|
||||
order: -1 !important;
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
padding: 0 4px !important;
|
||||
margin: 0 0 8px 0 !important;
|
||||
}
|
||||
.oasis-nav-side-toggle ~ .main-content .oasis-nav-header {
|
||||
margin: 0 0 6px 0 !important;
|
||||
padding: 12px !important;
|
||||
}
|
||||
.oasis-nav-side-toggle ~ .main-content .sidebar-left .oasis-nav-list,
|
||||
.oasis-nav-side-toggle ~ .main-content .sidebar-right .oasis-nav-list {
|
||||
display: grid !important;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr)) !important;
|
||||
gap: 8px !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
.oasis-nav-side-toggle ~ .main-content .oasis-nav-list li {
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
min-width: 0 !important;
|
||||
}
|
||||
.oasis-nav-side-toggle ~ .main-content .oasis-nav-list li a {
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
gap: 6px !important;
|
||||
min-height: 74px !important;
|
||||
height: 100% !important;
|
||||
padding: 8px 4px !important;
|
||||
margin: 0 !important;
|
||||
text-align: center !important;
|
||||
font-size: 0.72rem !important;
|
||||
opacity: 1 !important;
|
||||
box-sizing: border-box !important;
|
||||
}
|
||||
.oasis-nav-side-toggle ~ .main-content .oasis-nav-list li a .emoji {
|
||||
width: auto !important;
|
||||
font-size: 1.5rem !important;
|
||||
line-height: 1 !important;
|
||||
}
|
||||
.oasis-nav-side-toggle ~ .main-content .oasis-nav-list li a .nav-text {
|
||||
line-height: 1.15 !important;
|
||||
overflow-wrap: anywhere !important;
|
||||
}
|
||||
.oasis-nav-side-toggle ~ .header .top-bar-left nav,
|
||||
.oasis-nav-side-toggle ~ .header .top-bar-right nav {
|
||||
position: fixed !important;
|
||||
bottom: 0 !important;
|
||||
height: 66px !important;
|
||||
width: 50% !important;
|
||||
flex: none !important;
|
||||
z-index: 900 !important;
|
||||
margin: 0 !important;
|
||||
box-sizing: border-box !important;
|
||||
}
|
||||
.oasis-nav-side-toggle ~ .header .top-bar-left nav {
|
||||
left: 0 !important;
|
||||
right: auto !important;
|
||||
padding: 6px 2px 6px 6px !important;
|
||||
}
|
||||
.oasis-nav-side-toggle ~ .header .top-bar-right nav {
|
||||
right: 0 !important;
|
||||
left: auto !important;
|
||||
padding: 6px 6px 6px 2px !important;
|
||||
}
|
||||
.oasis-nav-side-toggle ~ .oasis-footer {
|
||||
padding-bottom: 84px !important;
|
||||
}
|
||||
body:has(:is(input:not([type="hidden"]):not([type="checkbox"]):not([type="radio"]), textarea, select):focus) .oasis-nav-side-toggle ~ .header .top-bar-left nav,
|
||||
body:has(:is(input:not([type="hidden"]):not([type="checkbox"]):not([type="radio"]), textarea, select):focus) .oasis-nav-side-toggle ~ .header .top-bar-right nav {
|
||||
display: none !important;
|
||||
}
|
||||
.oasis-nav-side-toggle ~ .header .top-bar-left nav {
|
||||
width: calc(300% / 7) !important;
|
||||
}
|
||||
.oasis-nav-side-toggle ~ .header .top-bar-right nav {
|
||||
width: calc(400% / 7) !important;
|
||||
}
|
||||
.oasis-nav-side-toggle ~ .header .top-bar-right nav ul {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr)) !important;
|
||||
}
|
||||
.oasis-nav-side-toggle ~ .main-content .sidebar-left nav > ul,
|
||||
.oasis-nav-side-toggle ~ .main-content .sidebar-right nav > ul {
|
||||
display: grid !important;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr)) !important;
|
||||
grid-auto-flow: row dense !important;
|
||||
gap: 8px !important;
|
||||
list-style: none !important;
|
||||
padding: 0 !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
.oasis-nav-side-toggle ~ .main-content .oasis-nav-group {
|
||||
display: contents !important;
|
||||
}
|
||||
.oasis-nav-side-toggle ~ .main-content .oasis-nav-group > .oasis-nav-header {
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
gap: 6px !important;
|
||||
min-height: 74px !important;
|
||||
margin: 0 !important;
|
||||
padding: 8px 4px !important;
|
||||
text-align: center !important;
|
||||
font-size: 0.68rem !important;
|
||||
box-sizing: border-box !important;
|
||||
}
|
||||
.oasis-nav-side-toggle ~ .main-content .oasis-nav-group > .oasis-nav-header .emoji {
|
||||
width: auto !important;
|
||||
font-size: 1.5rem !important;
|
||||
line-height: 1 !important;
|
||||
}
|
||||
.oasis-nav-side-toggle ~ .main-content .oasis-nav-group > .oasis-nav-header .nav-text {
|
||||
flex: 0 0 auto !important;
|
||||
line-height: 1.15 !important;
|
||||
overflow-wrap: anywhere !important;
|
||||
}
|
||||
.oasis-nav-side-toggle ~ .main-content .oasis-nav-group > .oasis-nav-header .oasis-nav-arrow {
|
||||
display: none !important;
|
||||
}
|
||||
.oasis-nav-side-toggle ~ .main-content .oasis-nav-group > .oasis-nav-list {
|
||||
grid-column: 1 / -1 !important;
|
||||
max-height: none !important;
|
||||
overflow: visible !important;
|
||||
}
|
||||
.oasis-nav-side-toggle ~ .main-content .oasis-nav-toggle:not(:checked) + .oasis-nav-header + .oasis-nav-list {
|
||||
display: none !important;
|
||||
}
|
||||
.oasis-nav-side-toggle ~ .header .oasis-phone-state {
|
||||
order: 1 !important;
|
||||
flex: 1 1 0 !important;
|
||||
display: flex !important;
|
||||
justify-content: center !important;
|
||||
align-items: center !important;
|
||||
align-self: center !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
min-width: 0 !important;
|
||||
}
|
||||
.oasis-nav-side-toggle ~ .header:has(.oasis-phone-state) .oasis-mobile-menus {
|
||||
flex: 0 1 auto !important;
|
||||
}
|
||||
.oasis-nav-side-toggle ~ .header .oasis-phone-state-btn {
|
||||
appearance: none !important;
|
||||
-webkit-appearance: none !important;
|
||||
font: inherit !important;
|
||||
display: inline-flex !important;
|
||||
align-items: center !important;
|
||||
gap: 3px !important;
|
||||
min-height: 0 !important;
|
||||
justify-content: center !important;
|
||||
width: 38px !important;
|
||||
height: 38px !important;
|
||||
padding: 0 !important;
|
||||
font-size: 8.5px !important;
|
||||
line-height: 1.2 !important;
|
||||
white-space: nowrap !important;
|
||||
border-radius: 999px !important;
|
||||
cursor: pointer !important;
|
||||
}
|
||||
.oasis-nav-side-toggle ~ .header .oasis-phone-state-btn .pm-exposition-icon {
|
||||
font-size: 22px !important;
|
||||
line-height: 1 !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
|
|
|||